@sakanano-moe/agenthelm 6.6.0 → 6.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/README.md +68 -0
  2. package/README.zh-CN.md +28 -0
  3. package/package.json +2 -2
  4. package/runtime/index.json +14 -14
  5. package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +11 -4
  6. package/runtime/plugins/agent-pack/plugin.json +1 -1
  7. package/runtime/plugins/agent-pack/references/capability-config.md +29 -5
  8. package/runtime/plugins/agent-pack/runtime/agent_pack_claude.py +448 -0
  9. package/runtime/plugins/agent-pack/runtime/agent_pack_config.py +178 -22
  10. package/runtime/plugins/agent-pack/runtime/mcp_server.py +30 -8
  11. package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +2 -2
  12. package/runtime/plugins/agents-init/.codex-plugin/plugin.json +5 -5
  13. package/runtime/plugins/agents-init/plugin.json +2 -2
  14. package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +1 -1
  15. package/runtime/plugins/agents-init/skills/agents-init/skill.json +3 -3
  16. package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +5 -5
  17. package/runtime/plugins/opinion-manager/plugin.json +1 -1
  18. package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +1 -1
  19. package/runtime/plugins/opinion-manager/skills/opinion-manager/agents/openai.yaml +2 -2
  20. package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +10 -4
  21. package/runtime/plugins/project-orchestrator/plugin.json +1 -1
  22. package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +1 -1
  23. package/runtime/plugins/project-orchestrator/skills/project-orchestrator/agents/openai.yaml +2 -2
@@ -0,0 +1,448 @@
1
+ """Claude Code projection of a project's capabilities: a dedicated package, sync /
2
+ reconcile, preserved local credential overrides, and drift reports.
3
+
4
+ Claude Code has no Agent Plugins loader, so a package is projected onto what it
5
+ reads: ``~/.claude/skills/<skill>`` (links), ``~/.claude.json#mcpServers`` (user
6
+ scope) and the install record ``~/.claude/agent-pack/plugins/<package>`` (a link
7
+ inventory uses to report the version).
8
+
9
+ ``sync`` / ``reconcile`` build a package filtered by the project's Claude policy
10
+ into ``~/.claude/agent-pack/packages/<package>/`` instead of borrowing another
11
+ client's package directory. ``~/.claude/agent-pack/state/<package>.json`` lists
12
+ the Skills and MCP servers this package put into Claude; only those are updated
13
+ or removed, entries the user added are never touched. Local credential values
14
+ (``headers`` / ``env``) on an MCP entry survive updates unless reset explicitly.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import os
21
+ import shutil
22
+ import stat
23
+ import tempfile
24
+ from datetime import UTC, datetime
25
+ from pathlib import Path
26
+ from typing import Any, Iterable
27
+
28
+ import agent_pack_config as core
29
+
30
+ OVERRIDE_CONTAINERS = ("headers", "env")
31
+ STATE_SCHEMA = 1
32
+
33
+
34
+ # ---------------------------------------------------------------- locations
35
+
36
+ def package_root(home: Path, name: str) -> Path:
37
+ return home / ".claude" / "agent-pack" / "packages" / name
38
+
39
+
40
+ def record_path(home: Path, name: str) -> Path:
41
+ return core._claude_plugin_records(home) / name
42
+
43
+
44
+ def state_path(home: Path, name: str) -> Path:
45
+ return home / ".claude" / "agent-pack" / "state" / f"{name}.json"
46
+
47
+
48
+ def data_root(home: Path, name: str) -> Path:
49
+ return home / ".claude" / "agent-pack" / "data" / name
50
+
51
+
52
+ # ---------------------------------------------------------------- local overrides
53
+
54
+ def _placeholder(value: Any) -> bool:
55
+ return value in (None, "") or (isinstance(value, str) and "${" in value)
56
+
57
+
58
+ def merge_overrides(rendered: dict[str, Any], current: Any, *, reset: bool = False) -> tuple[dict[str, Any], list[str]]:
59
+ """Keep the user's local credential values on top of the project's entry.
60
+
61
+ A ``headers`` / ``env`` key keeps its local value when the project entry
62
+ lacks it, leaves it as a placeholder, or the key is credential-like. Other
63
+ differences follow the project. Returns the merged entry and the kept key
64
+ names (never their values).
65
+ """
66
+ merged = json.loads(json.dumps(rendered))
67
+ if reset or not isinstance(current, dict):
68
+ return merged, []
69
+ kept: list[str] = []
70
+ for container in OVERRIDE_CONTAINERS:
71
+ local = current.get(container)
72
+ if not isinstance(local, dict):
73
+ continue
74
+ target = dict(merged.get(container) or {}) if isinstance(merged.get(container) or {}, dict) else {}
75
+ for key, value in local.items():
76
+ ours = target.get(key)
77
+ credential = bool(core.SECRET_NAME_RE.search(str(key)))
78
+ if ours != value and (key not in target or _placeholder(ours) or credential):
79
+ target[key] = value
80
+ kept.append(f"{container}.{key}")
81
+ if target:
82
+ merged[container] = target
83
+ return merged, sorted(kept)
84
+
85
+
86
+ def classify_mcp(rendered: dict[str, Any], current: Any, *, reset: bool = False) -> tuple[str, dict[str, Any], list[str]]:
87
+ """(state, entry to write, kept override keys); state is add / keep / keep-override / differ."""
88
+ if current is None:
89
+ return "add", json.loads(json.dumps(rendered)), []
90
+ merged, kept = merge_overrides(rendered, current, reset=reset)
91
+ if merged == current:
92
+ return ("keep-override" if kept else "keep"), merged, kept
93
+ return "differ", merged, kept
94
+
95
+
96
+ # ---------------------------------------------------------------- reading the client
97
+
98
+ def _read_state(home: Path, name: str) -> dict[str, Any]:
99
+ path = state_path(home, name)
100
+ if not path.is_file() or path.is_symlink():
101
+ return {}
102
+ try:
103
+ data = core._read_json(path)
104
+ except core.AgentPackError:
105
+ return {}
106
+ return data if data.get("schema_version") == STATE_SCHEMA else {}
107
+
108
+
109
+ def _read_config(home: Path) -> dict[str, Any]:
110
+ path = core._claude_user_config(home)
111
+ if not path.exists():
112
+ return {}
113
+ if path.is_symlink():
114
+ raise core.AgentPackError(f"refusing to edit symlinked Claude config: {path}")
115
+ config = core._read_json(path)
116
+ if not isinstance(config.get("mcpServers", {}), dict):
117
+ raise core.AgentPackError(f"mcpServers must be an object: {path}")
118
+ return config
119
+
120
+
121
+ def _strings(value: Any) -> Iterable[str]:
122
+ if isinstance(value, str):
123
+ yield value
124
+ elif isinstance(value, list):
125
+ for child in value:
126
+ yield from _strings(child)
127
+ elif isinstance(value, dict):
128
+ for child in value.values():
129
+ yield from _strings(child)
130
+
131
+
132
+ def _references(entry: Any, base: Path) -> bool:
133
+ prefix = str(base) + os.sep
134
+ return any(text == str(base) or prefix in text or text.startswith(prefix) for text in _strings(entry))
135
+
136
+
137
+ def _link_target(path: Path) -> Path | None:
138
+ if not path.is_symlink():
139
+ return None
140
+ target = Path(os.readlink(path))
141
+ return target if target.is_absolute() else (path.parent / target)
142
+
143
+
144
+ def _inside(path: Path, root: Path) -> bool:
145
+ try:
146
+ path.resolve().relative_to(root.resolve())
147
+ return True
148
+ except (ValueError, OSError):
149
+ return False
150
+
151
+
152
+ # ---------------------------------------------------------------- plan
153
+
154
+ def _render_package_mcps(package: Path, home: Path, name: str) -> dict[str, dict[str, Any]]:
155
+ servers: dict[str, dict[str, Any]] = {}
156
+ mcp_path = package / "mcp.json"
157
+ if not mcp_path.is_file():
158
+ return servers
159
+ for server, raw in sorted(core._read_json(mcp_path).get("mcpServers", {}).items()):
160
+ parsed = core._portable_mcp(server, raw)
161
+ if not parsed.portable:
162
+ raise core.AgentPackError(f"MCP {server} is not portable: " + "; ".join(parsed.issues))
163
+ servers[server] = core._claude_mcp_config(parsed.config, record_path(home, name), data_root(home, name))
164
+ return servers
165
+
166
+
167
+ def plan_project(project: Path, home: Path, *, built: Path, reset_overrides: bool = False,
168
+ retire_skills: Iterable[str] = (), retire_plugins: Iterable[str] = (),
169
+ retire_only: bool = False) -> dict[str, Any]:
170
+ """Every change a Claude sync would make, without writing; conflicts are listed, not raised."""
171
+ layout = core.load_project_layout(project)
172
+ authority = core.inventory_project(layout.root)
173
+ name = core._package_name(layout.root)
174
+ package, record = package_root(home, name), record_path(home, name)
175
+ state = _read_state(home, name)
176
+ config = _read_config(home)
177
+ existing = config.get("mcpServers", {})
178
+ old_target = _link_target(record)
179
+ legacy_root = old_target if old_target is not None and not _inside(old_target, package) else None
180
+
181
+ desired_skills = {child.name for child in (built / "skills").iterdir()
182
+ if child.is_dir() and (child / "SKILL.md").is_file()} if (built / "skills").is_dir() else set()
183
+ desired_mcps = _render_package_mcps(built, home, name)
184
+ excluded = sorted(item.name for item in authority.mcps
185
+ if item.portable and not core._mcp_allowed(layout, item.name, "claude"))
186
+ owned_mcps = set(state.get("mcps", {}))
187
+ owned_mcps |= {server for server, entry in existing.items() if _references(entry, record)}
188
+ owned_skills = set(state.get("skills", []))
189
+
190
+ operations: list[dict[str, Any]] = []
191
+ conflicts: list[str] = []
192
+ overrides: list[dict[str, Any]] = []
193
+
194
+ if not retire_only:
195
+ if package.exists() and not package.is_symlink():
196
+ same = core._tree_fingerprint(package) == core._tree_fingerprint(built)
197
+ operations.append({"action": "keep-package" if same else "replace-package", "name": name, "to": str(package)})
198
+ else:
199
+ operations.append({"action": "install-package", "name": name, "to": str(package)})
200
+ if old_target is not None and package.is_dir() and old_target.resolve() == package.resolve():
201
+ operations.append({"action": "keep-record", "name": name, "to": str(record)})
202
+ elif record.is_symlink() or record.exists():
203
+ detail = {"from": str(old_target)} if old_target is not None else {}
204
+ operations.append({"action": "relink-record", "name": name, "to": str(record), **detail})
205
+ else:
206
+ operations.append({"action": "link-record", "name": name, "to": str(record)})
207
+
208
+ skills_root = home / ".claude" / "skills"
209
+ for skill in sorted(desired_skills):
210
+ path, target = skills_root / skill, package / "skills" / skill
211
+ link = _link_target(path)
212
+ ours = skill in owned_skills or (link is not None and (
213
+ _inside(link, package) or (legacy_root is not None and _inside(link, legacy_root))))
214
+ if not path.exists() and not path.is_symlink():
215
+ action = "link-skill"
216
+ elif link is not None and link == target:
217
+ action = "keep-skill"
218
+ elif ours:
219
+ action = "relink-skill"
220
+ else:
221
+ action = "conflict-skill"
222
+ conflicts.append(f"skill {skill}: {path}")
223
+ operations.append({"action": action, "name": skill, "to": str(path)})
224
+ for skill in sorted(owned_skills - desired_skills):
225
+ path = skills_root / skill
226
+ if path.is_symlink() or path.exists():
227
+ operations.append({"action": "remove-skill", "name": skill, "from": str(path)})
228
+ if legacy_root is not None and skills_root.is_dir():
229
+ for path in sorted(skills_root.iterdir()):
230
+ link = _link_target(path)
231
+ if (path.name not in desired_skills and path.name not in owned_skills and link is not None
232
+ and _inside(link, legacy_root)):
233
+ operations.append({"action": "remove-skill", "name": path.name, "from": str(path)})
234
+
235
+ for server, rendered in desired_mcps.items():
236
+ state_name, entry, kept = classify_mcp(rendered, existing.get(server), reset=reset_overrides)
237
+ if state_name == "differ":
238
+ if server in owned_mcps:
239
+ state_name = "update"
240
+ else:
241
+ state_name = "conflict"
242
+ conflicts.append(f"mcp {server}: {core._claude_user_config(home)}")
243
+ if kept:
244
+ overrides.append({"name": server, "keys": kept})
245
+ operations.append({"action": f"{state_name}-mcp", "name": server, "keys": kept, "_entry": entry})
246
+ for server in sorted(owned_mcps - set(desired_mcps)):
247
+ if server in existing:
248
+ operations.append({"action": "remove-mcp", "name": server,
249
+ "reason": "excluded for claude" if server in excluded else "no longer in project"})
250
+
251
+ client_inventory = core.inventory_client("claude", home)
252
+ actual_skills = {item.name: Path(item.path) for item in client_inventory.skills}
253
+ actual_plugins = {str(item.get("name")): Path(str(item.get("path")))
254
+ for item in client_inventory.plugins if item.get("name") and item.get("path")}
255
+ for skill in sorted(set(retire_skills)):
256
+ if skill in desired_skills:
257
+ raise core.AgentPackError(f"cannot retire Skill provided by the project package: {skill}")
258
+ path = actual_skills.get(skill, home / ".claude" / "skills" / skill)
259
+ if not path.exists() and not path.is_symlink():
260
+ raise core.AgentPackError(f"retire Skill not installed: {skill}")
261
+ operations.append({"action": "archive-skill", "name": skill, "from": str(path)})
262
+ for plugin in sorted(set(retire_plugins)):
263
+ if plugin == name:
264
+ raise core.AgentPackError(f"cannot retire active project package: {plugin}")
265
+ path = actual_plugins.get(plugin)
266
+ if path is None:
267
+ raise core.AgentPackError(f"retire plugin not installed: {plugin}")
268
+ operations.append({"action": "archive-plugin", "name": plugin, "from": str(path)})
269
+ if retire_only and not (retire_skills or retire_plugins):
270
+ raise core.AgentPackError("retire-only reconcile needs at least one --retire-skill or --retire-plugin")
271
+
272
+ native = sorted(server for server in existing if server not in desired_mcps and server not in owned_mcps)
273
+ return {"name": name, "layout": layout, "operations": operations, "conflicts": conflicts,
274
+ "retire_only": retire_only,
275
+ "overrides": overrides, "excluded": [server for server in excluded if server in existing],
276
+ "native": native, "legacy_root": str(legacy_root) if legacy_root else None,
277
+ "desired_skills": sorted(desired_skills), "desired_mcps": sorted(desired_mcps)}
278
+
279
+
280
+ def _public(operations: list[dict[str, Any]]) -> list[dict[str, Any]]:
281
+ return [{key: value for key, value in item.items() if not key.startswith("_") and value != []}
282
+ | {"client": "claude"} for item in operations]
283
+
284
+
285
+ # ---------------------------------------------------------------- apply
286
+
287
+ def sync_project(project: Path, *, home: Path | None = None, apply: bool = False, replace: bool = False,
288
+ reset_overrides: bool = False, recovery_root: Path | None = None,
289
+ retire_skills: Iterable[str] = (), retire_plugins: Iterable[str] = (),
290
+ retire_only: bool = False) -> dict[str, Any]:
291
+ home = (home or Path.home()).expanduser().resolve()
292
+ retire_skills, retire_plugins = list(retire_skills), list(retire_plugins)
293
+ with tempfile.TemporaryDirectory(prefix="agent-pack-claude-") as temporary:
294
+ layout = core.load_project_layout(project)
295
+ authority = core.inventory_project(layout.root)
296
+ name = core._package_name(layout.root)
297
+ built = core._build_package(authority, name, Path(temporary), "claude")
298
+ plan = plan_project(project, home, built=built, reset_overrides=reset_overrides,
299
+ retire_skills=retire_skills, retire_plugins=retire_plugins, retire_only=retire_only)
300
+ if plan["conflicts"] and not replace:
301
+ raise core.AgentPackError("Claude client already has different content not installed by this package "
302
+ "(use --replace; replaced items go to recovery): " + "; ".join(plan["conflicts"]))
303
+ result: dict[str, Any] = {
304
+ "ok": True, "applied": False, "client": "claude", "package": name,
305
+ "operations": _public(plan["operations"]), "overrides": plan["overrides"],
306
+ "excluded": plan["excluded"], "native": plan["native"],
307
+ "notes": ["restart Claude Code to load changed Skills and MCP servers"],
308
+ }
309
+ if plan["legacy_root"]:
310
+ result["migrated_from"] = plan["legacy_root"]
311
+ changes = [item for item in plan["operations"] if not item["action"].startswith("keep-")]
312
+ if not apply or not changes:
313
+ result["applied"] = apply and not changes
314
+ return result
315
+ result["recovery"] = str(_apply(plan, built, home, recovery_root))
316
+ result["applied"] = True
317
+ return result
318
+
319
+
320
+ def _apply(plan: dict[str, Any], built: Path, home: Path, recovery_root: Path | None) -> Path:
321
+ name = plan["name"]
322
+ package, record = package_root(home, name), record_path(home, name)
323
+ recovery_base = (recovery_root or home / ".agent-pack" / "recovery").expanduser().resolve()
324
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S.%fZ")
325
+ transaction = recovery_base / f"{stamp}-claude-{name}"
326
+ transaction.mkdir(parents=True, mode=0o700)
327
+ config_path = core._claude_user_config(home)
328
+ config = _read_config(home)
329
+ original_config = json.loads(json.dumps(config))
330
+ config_written = False
331
+ moved: list[tuple[Path, Path]] = [] # (archived, original)
332
+ created: list[Path] = []
333
+ actions = {(item["action"], item["name"]): item for item in plan["operations"]}
334
+
335
+ def archive(path: Path, bucket: str, item_name: str) -> None:
336
+ archived = transaction / bucket / item_name
337
+ archived.parent.mkdir(parents=True, exist_ok=True)
338
+ os.replace(path, archived)
339
+ moved.append((archived, path))
340
+
341
+ def link(path: Path, target: Path) -> None:
342
+ path.parent.mkdir(parents=True, exist_ok=True)
343
+ os.symlink(target, path, target_is_directory=True)
344
+ created.append(path)
345
+
346
+ try:
347
+ if ("install-package", name) in actions or ("replace-package", name) in actions:
348
+ package.parent.mkdir(parents=True, exist_ok=True)
349
+ staging = Path(tempfile.mkdtemp(prefix=".agent-pack-", dir=package.parent))
350
+ try:
351
+ staged = staging / name
352
+ shutil.copytree(built, staged, symlinks=False)
353
+ if package.exists():
354
+ archive(package, "replaced-package", name)
355
+ os.replace(staged, package)
356
+ created.append(package)
357
+ finally:
358
+ shutil.rmtree(staging, ignore_errors=True)
359
+ if ("relink-record", name) in actions or ("link-record", name) in actions:
360
+ if record.is_symlink() or record.exists():
361
+ previous = _link_target(record)
362
+ core._write_json_atomic(transaction / "record.json",
363
+ {"record": str(record), "target": str(previous) if previous else None}, mode=0o600)
364
+ archive(record, "replaced-records", name)
365
+ link(record, package)
366
+ for item in plan["operations"]:
367
+ action, item_name = item["action"], item["name"]
368
+ if action in ("relink-skill", "conflict-skill", "remove-skill", "archive-skill"):
369
+ path = Path(item.get("to") or item["from"])
370
+ if path.is_symlink() or path.exists():
371
+ archive(path, "replaced-skills" if action != "archive-skill" else "skills", item_name)
372
+ if action in ("link-skill", "relink-skill", "conflict-skill"):
373
+ link(Path(item["to"]), package / "skills" / item_name)
374
+ if action == "archive-plugin":
375
+ archive(Path(item["from"]), "plugins", item_name)
376
+ servers = config.setdefault("mcpServers", {})
377
+ replaced: dict[str, Any] = {}
378
+ mcp_state: dict[str, str] = {}
379
+ for item in plan["operations"]:
380
+ action, server = item["action"], item["name"]
381
+ if action in ("add-mcp", "update-mcp", "conflict-mcp", "keep-mcp", "keep-override-mcp"):
382
+ if action in ("update-mcp", "conflict-mcp") and server in servers:
383
+ replaced[server] = servers[server]
384
+ servers[server] = item["_entry"]
385
+ mcp_state[server] = hashlib.sha256(
386
+ json.dumps(item["_entry"], sort_keys=True).encode("utf-8")).hexdigest()
387
+ elif action == "remove-mcp":
388
+ replaced[server] = servers.pop(server)
389
+ if replaced:
390
+ core._write_json_atomic(transaction / "replaced-mcp.json", {"mcpServers": replaced}, mode=0o600)
391
+ if config != original_config:
392
+ mode = stat.S_IMODE(config_path.stat().st_mode) if config_path.exists() else 0o600
393
+ core._write_json_atomic(config_path, config, mode=mode)
394
+ config_written = True
395
+ if not plan["retire_only"]:
396
+ core._write_json_atomic(state_path(home, name), {
397
+ "schema_version": STATE_SCHEMA, "package": name, "skills": plan["desired_skills"],
398
+ "mcps": mcp_state, "updated_at": stamp}, mode=0o600)
399
+ except Exception as exc:
400
+ if config_written:
401
+ core._write_json_atomic(config_path, original_config, mode=0o600)
402
+ for path in reversed(created):
403
+ if path.is_symlink():
404
+ path.unlink()
405
+ elif path.is_dir():
406
+ shutil.rmtree(path)
407
+ for archived, original in reversed(moved):
408
+ if (archived.exists() or archived.is_symlink()) and not (original.exists() or original.is_symlink()):
409
+ original.parent.mkdir(parents=True, exist_ok=True)
410
+ os.replace(archived, original)
411
+ raise core.AgentPackError(f"claude sync rolled back: {exc}") from exc
412
+ return transaction
413
+
414
+
415
+ # ---------------------------------------------------------------- drift (doctor)
416
+
417
+ def drift(project: Path, home: Path | None = None) -> dict[str, Any]:
418
+ home = (home or Path.home()).expanduser().resolve()
419
+ with tempfile.TemporaryDirectory(prefix="agent-pack-claude-") as temporary:
420
+ layout = core.load_project_layout(project)
421
+ name = core._package_name(layout.root)
422
+ built = core._build_package(core.inventory_project(layout.root), name, Path(temporary), "claude")
423
+ plan = plan_project(project, home, built=built)
424
+ report: dict[str, Any] = {"client": "claude", "package": name, "missing": [], "different": [],
425
+ "overrides": plan["overrides"], "stale": [], "native": plan["native"],
426
+ "excluded": plan["excluded"], "dangling": [], "coupled": plan["legacy_root"]}
427
+ for item in plan["operations"]:
428
+ action, item_name = item["action"], item["name"]
429
+ if action in ("install-package", "link-record", "link-skill", "add-mcp"):
430
+ report["missing"].append(f"{action.split('-')[1]} {item_name}")
431
+ elif action in ("replace-package", "relink-skill", "conflict-skill", "update-mcp", "conflict-mcp"):
432
+ report["different"].append(f"{action.split('-')[-1]} {item_name}")
433
+ elif action in ("remove-skill", "remove-mcp"):
434
+ report["stale"].append(f"{action.split('-')[1]} {item_name}")
435
+ skills_root = home / ".claude" / "skills"
436
+ if skills_root.is_dir():
437
+ report["dangling"] += [f"skill {path.name}" for path in sorted(skills_root.iterdir())
438
+ if path.is_symlink() and not path.exists()]
439
+ record = record_path(home, name)
440
+ config = _read_config(home).get("mcpServers", {})
441
+ for server, entry in sorted(config.items()):
442
+ for text in _strings(entry):
443
+ if text.startswith(str(record) + os.sep) and not Path(text).exists():
444
+ report["dangling"].append(f"mcp {server}: {text}")
445
+ break
446
+ report["in_sync"] = not (report["missing"] or report["different"] or report["stale"]
447
+ or report["dangling"] or report["coupled"])
448
+ return report