@softspark/ai-toolkit 2.1.3 → 2.3.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.
@@ -60,6 +60,31 @@ def get_installed_profile() -> str:
60
60
  return state.get("profile", "")
61
61
 
62
62
 
63
+ def get_mcp_templates() -> list[str]:
64
+ """Return list of globally tracked MCP template names."""
65
+ state = load_state()
66
+ templates = state.get("mcp_templates", [])
67
+ return templates if isinstance(templates, list) else []
68
+
69
+
70
+ def record_mcp_template(name: str) -> None:
71
+ """Add a template name to the tracked set in state.json."""
72
+ state = load_state()
73
+ templates = set(state.get("mcp_templates", []))
74
+ templates.add(name)
75
+ state["mcp_templates"] = sorted(templates)
76
+ save_state(state)
77
+
78
+
79
+ def remove_mcp_template(name: str) -> None:
80
+ """Remove a template name from the tracked set in state.json."""
81
+ state = load_state()
82
+ templates = set(state.get("mcp_templates", []))
83
+ templates.discard(name)
84
+ state["mcp_templates"] = sorted(templates)
85
+ save_state(state)
86
+
87
+
63
88
  def _now_iso() -> str:
64
89
  """Return current UTC time in ISO 8601 format."""
65
90
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -138,6 +163,10 @@ def print_status() -> None:
138
163
  langs = [m.replace("rules-", "") for m in detected]
139
164
  print(f" Detected: {', '.join(langs)}")
140
165
 
166
+ mcp = state.get("mcp_templates", [])
167
+ if mcp:
168
+ print(f" MCP: {', '.join(mcp)}")
169
+
141
170
  extends = state.get("extends")
142
171
  if extends:
143
172
  print(f" Extends: {extends.get('source', 'unknown')}")
@@ -32,14 +32,23 @@ def install_marker_files(claude_dir: Path, only: str, skip: str,
32
32
 
33
33
 
34
34
  def inject_rules(claude_dir: Path, target_dir: Path, rules_dir: Path,
35
- only: str, skip: str, dry_run: bool) -> None:
36
- """Inject rules into CLAUDE.md."""
35
+ only: str, skip: str, dry_run: bool,
36
+ refresh_urls: bool = False) -> None:
37
+ """Inject rules into CLAUDE.md.
38
+
39
+ When refresh_urls is True, re-fetches URL-sourced rules before injection.
40
+ Only the global install path should set this to True (once per update).
41
+ """
37
42
  claude_md = claude_dir / "CLAUDE.md"
38
43
 
39
44
  if dry_run:
40
45
  _inject_rules_dry_run(rules_dir)
41
46
  return
42
47
 
48
+ # Refresh URL-sourced rules before injection (global update only)
49
+ if refresh_urls:
50
+ _refresh_url_rules(rules_dir)
51
+
43
52
  if not claude_md.is_file():
44
53
  claude_md.touch()
45
54
  print(" Created: ~/.claude/CLAUDE.md")
@@ -66,6 +75,30 @@ def inject_rules(claude_dir: Path, target_dir: Path, rules_dir: Path,
66
75
  print(f" Rules injected: {' '.join(rules_injected)}")
67
76
 
68
77
 
78
+ def _refresh_url_rules(rules_dir: Path) -> None:
79
+ """Re-fetch all URL-sourced rules. Warn on failure, use cached copy."""
80
+ from rule_sources import get_url_rules, fetch_url, register_url_source
81
+
82
+ url_rules = get_url_rules(rules_dir)
83
+ if not url_rules:
84
+ return
85
+
86
+ for rule_name, url in url_rules.items():
87
+ rule_file = rules_dir / f"{rule_name}.md"
88
+ try:
89
+ data = fetch_url(url)
90
+ rule_file.write_bytes(data)
91
+ register_url_source(rules_dir, rule_name, url)
92
+ print(f" Refreshed: {rule_name} (from {url})")
93
+ except Exception as exc:
94
+ if rule_file.is_file():
95
+ print(f" Warning: could not refresh '{rule_name}' from {url}: {exc}")
96
+ print(f" Using cached version.")
97
+ else:
98
+ print(f" Warning: could not fetch '{rule_name}' from {url}: {exc}")
99
+ print(f" No cached version — rule will be skipped.")
100
+
101
+
69
102
  def _inject_rules_dry_run(rules_dir: Path) -> None:
70
103
  rules_src = app_dir / "rules"
71
104
  rule_names = " ".join(
@@ -8,15 +8,43 @@ Stdlib-only — no external dependencies.
8
8
  """
9
9
  from __future__ import annotations
10
10
 
11
+ import contextlib
12
+ import fcntl
11
13
  import json
14
+ import os
12
15
  import sys
16
+ import tempfile
17
+ import time
13
18
  from datetime import datetime, timezone
14
19
  from pathlib import Path
15
- from typing import Any
20
+ from typing import Any, Generator
16
21
 
17
22
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
23
  from paths import PROJECTS_FILE
19
24
 
25
+ # Max retries when reading a partially-written file
26
+ _LOAD_RETRIES = 3
27
+ _LOAD_RETRY_DELAY = 0.05 # 50ms
28
+
29
+
30
+ @contextlib.contextmanager
31
+ def _registry_lock() -> Generator[None, None, None]:
32
+ """Exclusive file lock for read-modify-write on projects.json.
33
+
34
+ Prevents concurrent processes from interleaving loads and saves,
35
+ which can silently drop entries.
36
+ """
37
+ path = _registry_path()
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+ lock_path = path.with_suffix(".lock")
40
+ fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR)
41
+ try:
42
+ fcntl.flock(fd, fcntl.LOCK_EX)
43
+ yield
44
+ finally:
45
+ fcntl.flock(fd, fcntl.LOCK_UN)
46
+ os.close(fd)
47
+
20
48
 
21
49
  def _registry_path() -> Path:
22
50
  """Return the canonical path to projects.json."""
@@ -32,28 +60,66 @@ def _now_iso() -> str:
32
60
  # ---------------------------------------------------------------------------
33
61
 
34
62
  def load_registry() -> list[dict[str, Any]]:
35
- """Load project registry. Returns empty list if missing/corrupt."""
63
+ """Load project registry with retry for partially-written files.
64
+
65
+ Retries on JSONDecodeError (another process mid-write).
66
+ Returns empty list only if the file genuinely doesn't exist.
67
+ """
36
68
  path = _registry_path()
37
69
  if not path.is_file():
38
70
  return []
39
- try:
40
- with open(path, encoding="utf-8") as f:
41
- data = json.load(f)
42
- if isinstance(data, dict):
43
- projects = data.get("projects", [])
44
- return projects if isinstance(projects, list) else []
45
- return []
46
- except (json.JSONDecodeError, OSError):
47
- return []
71
+
72
+ last_err: Exception | None = None
73
+ for attempt in range(_LOAD_RETRIES):
74
+ try:
75
+ with open(path, encoding="utf-8") as f:
76
+ data = json.load(f)
77
+ if isinstance(data, dict):
78
+ projects = data.get("projects", [])
79
+ return projects if isinstance(projects, list) else []
80
+ return []
81
+ except json.JSONDecodeError as exc:
82
+ last_err = exc
83
+ if attempt < _LOAD_RETRIES - 1:
84
+ time.sleep(_LOAD_RETRY_DELAY)
85
+ except OSError:
86
+ return []
87
+
88
+ # All retries exhausted — file is genuinely corrupt, not mid-write
89
+ import sys as _sys
90
+ print(
91
+ f"Warning: {path} is corrupt after {_LOAD_RETRIES} retries: {last_err}",
92
+ file=_sys.stderr,
93
+ )
94
+ return []
48
95
 
49
96
 
50
97
  def save_registry(projects: list[dict[str, Any]]) -> None:
51
- """Save project registry."""
98
+ """Save project registry atomically (write-to-temp + rename).
99
+
100
+ Uses os.rename which is atomic on POSIX, preventing other processes
101
+ from reading a partially-written file.
102
+ """
52
103
  path = _registry_path()
53
104
  path.parent.mkdir(parents=True, exist_ok=True)
54
- with open(path, "w", encoding="utf-8") as f:
55
- json.dump({"projects": projects}, f, indent=2)
56
- f.write("\n")
105
+
106
+ fd, tmp_path = tempfile.mkstemp(
107
+ dir=str(path.parent), prefix=".projects_", suffix=".tmp"
108
+ )
109
+ try:
110
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
111
+ json.dump({"projects": projects}, f, indent=2)
112
+ f.write("\n")
113
+ f.flush()
114
+ os.fsync(f.fileno())
115
+ os.rename(tmp_path, str(path))
116
+ except BaseException:
117
+ # Clean up temp file on failure
118
+ try:
119
+ os.unlink(tmp_path)
120
+ except OSError:
121
+ pass
122
+ raise
57
123
 
58
124
 
59
125
  # ---------------------------------------------------------------------------
@@ -68,47 +134,52 @@ def register_project(
68
134
  """Register a project directory. Returns True if newly added, False if updated.
69
135
 
70
136
  Idempotent — updates existing entry if path already registered.
137
+ Uses file lock to prevent concurrent read-modify-write races.
71
138
  """
72
139
  project_path = str(Path(project_path).resolve())
73
- projects = load_registry()
74
- now = _now_iso()
75
-
76
- for p in projects:
77
- if p.get("path") == project_path:
78
- # Update existing
79
- p["last_updated"] = now
80
- if profile:
81
- p["profile"] = profile
82
- if extends:
83
- p["extends"] = extends
84
- elif "extends" in p and not extends:
85
- # Clear extends if project no longer uses it
86
- pass
87
- save_registry(projects)
88
- return False
89
140
 
90
- # New registration
91
- projects.append({
92
- "path": project_path,
93
- "registered_at": now,
94
- "last_updated": now,
95
- "profile": profile or "standard",
96
- "extends": extends or "",
97
- })
98
- save_registry(projects)
99
- return True
141
+ with _registry_lock():
142
+ projects = load_registry()
143
+ now = _now_iso()
144
+
145
+ for p in projects:
146
+ if p.get("path") == project_path:
147
+ # Update existing
148
+ p["last_updated"] = now
149
+ if profile:
150
+ p["profile"] = profile
151
+ if extends:
152
+ p["extends"] = extends
153
+ elif "extends" in p and not extends:
154
+ # Clear extends if project no longer uses it
155
+ pass
156
+ save_registry(projects)
157
+ return False
158
+
159
+ # New registration
160
+ projects.append({
161
+ "path": project_path,
162
+ "registered_at": now,
163
+ "last_updated": now,
164
+ "profile": profile or "standard",
165
+ "extends": extends or "",
166
+ })
167
+ save_registry(projects)
168
+ return True
100
169
 
101
170
 
102
171
  def unregister_project(project_path: str | Path) -> bool:
103
172
  """Unregister a project. Returns True if found and removed."""
104
173
  project_path = str(Path(project_path).resolve())
105
- projects = load_registry()
106
- original_len = len(projects)
107
- projects = [p for p in projects if p.get("path") != project_path]
108
- if len(projects) < original_len:
109
- save_registry(projects)
110
- return True
111
- return False
174
+
175
+ with _registry_lock():
176
+ projects = load_registry()
177
+ original_len = len(projects)
178
+ projects = [p for p in projects if p.get("path") != project_path]
179
+ if len(projects) < original_len:
180
+ save_registry(projects)
181
+ return True
182
+ return False
112
183
 
113
184
 
114
185
  def list_projects() -> list[dict[str, Any]]:
@@ -121,18 +192,19 @@ def list_projects() -> list[dict[str, Any]]:
121
192
 
122
193
  def prune_stale() -> list[str]:
123
194
  """Remove projects whose directories no longer exist. Returns pruned paths."""
124
- projects = load_registry()
125
- pruned: list[str] = []
126
- kept: list[dict[str, Any]] = []
127
-
128
- for p in projects:
129
- if Path(p["path"]).is_dir():
130
- kept.append(p)
131
- else:
132
- pruned.append(p["path"])
133
-
134
- if pruned:
135
- save_registry(kept)
195
+ with _registry_lock():
196
+ projects = load_registry()
197
+ pruned: list[str] = []
198
+ kept: list[dict[str, Any]] = []
199
+
200
+ for p in projects:
201
+ if Path(p["path"]).is_dir():
202
+ kept.append(p)
203
+ else:
204
+ pruned.append(p["path"])
205
+
206
+ if pruned:
207
+ save_registry(kept)
136
208
 
137
209
  return pruned
138
210
 
@@ -15,8 +15,8 @@ except ModuleNotFoundError: # pragma: no cover - Python 3.11+ should have tomll
15
15
  EDITOR_SPECS: dict[str, dict[str, str | None]] = {
16
16
  "claude": {
17
17
  "label": "Claude Code",
18
- "project_path": ".claude/settings.local.json",
19
- "global_path": ".claude/settings.json",
18
+ "project_path": ".mcp.json",
19
+ "global_path": ".claude.json",
20
20
  "format": "json",
21
21
  "doc_scope": "project + global",
22
22
  },
@@ -130,6 +130,11 @@ def cmd_show(name: str) -> None:
130
130
  for key, val in env_vars:
131
131
  print(f" {key} = {val}")
132
132
 
133
+ post_install = data.get("postInstall")
134
+ if post_install:
135
+ print()
136
+ print(f"Setup: {post_install}")
137
+
133
138
 
134
139
  def cmd_add(names: list[str], target_dir: Path) -> None:
135
140
  """Add one or more MCP templates to .mcp.json."""
@@ -155,6 +160,13 @@ def cmd_add(names: list[str], target_dir: Path) -> None:
155
160
  write_mcp_config(target_dir, config)
156
161
  print(f"Added: {', '.join(added)}")
157
162
 
163
+ # Show postInstall hints from added templates
164
+ for name in names:
165
+ data = load_template(name)
166
+ post_install = data.get("postInstall")
167
+ if post_install:
168
+ print(f"\n Note ({name}): {post_install}")
169
+
158
170
 
159
171
  def cmd_install(
160
172
  names: list[str],
@@ -203,6 +215,20 @@ def cmd_install(
203
215
  for path in updated:
204
216
  print(f"Updated: {path}")
205
217
 
218
+ # Track globally installed templates in state.json
219
+ if eff_scope == "global" and names:
220
+ from install_steps.install_state import record_mcp_template
221
+ for name in names:
222
+ record_mcp_template(name)
223
+
224
+ # Show postInstall hints from installed templates
225
+ if names:
226
+ for name in names:
227
+ data = load_template(name)
228
+ post_install = data.get("postInstall")
229
+ if post_install:
230
+ print(f"\n Note ({name}): {post_install}")
231
+
206
232
 
207
233
  def cmd_remove(name: str, target_dir: Path | None, *, editors: list[str], scope: str | None) -> None:
208
234
  """Remove an MCP server from .mcp.json."""
@@ -223,6 +249,9 @@ def cmd_remove(name: str, target_dir: Path | None, *, editors: list[str], scope:
223
249
  )
224
250
  else:
225
251
  updated = remove_servers(editors, [name], scope="global")
252
+ # Untrack globally removed template from state.json
253
+ from install_steps.install_state import remove_mcp_template
254
+ remove_mcp_template(name)
226
255
  for path in updated:
227
256
  print(f"Updated: {path}")
228
257
  print(f"Removed: {name}")
@@ -43,6 +43,11 @@ def main() -> None:
43
43
  else:
44
44
  print(f"Not registered: '{rule_name}' not found in {rules_dir}")
45
45
 
46
+ # 1b. Clean up URL source metadata (if any)
47
+ from rule_sources import unregister_source
48
+ if unregister_source(rules_dir, rule_name):
49
+ print(f"Removed URL source for '{rule_name}'")
50
+
46
51
  # 2. Strip injected block from .claude/CLAUDE.md
47
52
  found = remove_rule_section(rule_name, target_dir)
48
53
  if found:
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python3
2
+ """URL source registry for remotely-sourced rules.
3
+
4
+ Tracks which rules were registered from a URL so that `ai-toolkit update`
5
+ can re-fetch the latest version before injection.
6
+
7
+ Metadata stored in ~/.softspark/ai-toolkit/rules/sources.json.
8
+
9
+ Stdlib-only — no external dependencies.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import ssl
16
+ import sys
17
+ import tempfile
18
+ import urllib.request
19
+ import urllib.error
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
25
+ from paths import RULES_DIR
26
+
27
+ _SOURCES_FILENAME = "sources.json"
28
+ _FETCH_TIMEOUT = 30 # seconds
29
+ _FETCH_MAX_BYTES = 10 * 1024 * 1024 # 10MB
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Load / Save
34
+ # ---------------------------------------------------------------------------
35
+
36
+ def _sources_path(rules_dir: Path | None = None) -> Path:
37
+ return (rules_dir or RULES_DIR) / _SOURCES_FILENAME
38
+
39
+
40
+ def load_sources(rules_dir: Path | None = None) -> dict[str, dict[str, Any]]:
41
+ """Load sources.json. Returns {} if missing or corrupt."""
42
+ path = _sources_path(rules_dir)
43
+ if not path.is_file():
44
+ return {}
45
+ try:
46
+ with open(path, encoding="utf-8") as f:
47
+ data = json.load(f)
48
+ if isinstance(data, dict):
49
+ return data.get("rules", {})
50
+ return {}
51
+ except (json.JSONDecodeError, OSError):
52
+ return {}
53
+
54
+
55
+ def save_sources(rules_dir: Path | None = None,
56
+ sources: dict[str, dict[str, Any]] | None = None) -> None:
57
+ """Write sources.json atomically."""
58
+ rules_dir = rules_dir or RULES_DIR
59
+ path = _sources_path(rules_dir)
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+
62
+ payload = json.dumps({"schema_version": 1, "rules": sources or {}}, indent=2)
63
+
64
+ fd, tmp_path = tempfile.mkstemp(
65
+ dir=str(path.parent), prefix=".sources_", suffix=".tmp"
66
+ )
67
+ try:
68
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
69
+ f.write(payload)
70
+ f.write("\n")
71
+ f.flush()
72
+ os.fsync(f.fileno())
73
+ os.rename(tmp_path, str(path))
74
+ except BaseException:
75
+ try:
76
+ os.unlink(tmp_path)
77
+ except OSError:
78
+ pass
79
+ raise
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # CRUD
84
+ # ---------------------------------------------------------------------------
85
+
86
+ def register_url_source(rules_dir: Path | None, rule_name: str, url: str) -> None:
87
+ """Add or update a URL source entry."""
88
+ rules_dir = rules_dir or RULES_DIR
89
+ sources = load_sources(rules_dir)
90
+ sources[rule_name] = {
91
+ "url": url,
92
+ "fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
93
+ }
94
+ save_sources(rules_dir, sources)
95
+
96
+
97
+ def unregister_source(rules_dir: Path | None, rule_name: str) -> bool:
98
+ """Remove a source entry. Returns True if found and removed."""
99
+ rules_dir = rules_dir or RULES_DIR
100
+ sources = load_sources(rules_dir)
101
+ if rule_name in sources:
102
+ del sources[rule_name]
103
+ save_sources(rules_dir, sources)
104
+ return True
105
+ return False
106
+
107
+
108
+ def get_url_rules(rules_dir: Path | None = None) -> dict[str, str]:
109
+ """Return {rule_name: url} for all URL-sourced rules."""
110
+ sources = load_sources(rules_dir)
111
+ return {name: entry["url"] for name, entry in sources.items() if "url" in entry}
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Fetch
116
+ # ---------------------------------------------------------------------------
117
+
118
+ def fetch_url(url: str) -> bytes:
119
+ """Fetch URL content. HTTPS only, 30s timeout, 10MB cap.
120
+
121
+ Raises:
122
+ ValueError: if URL is not HTTPS
123
+ urllib.error.URLError: on network failure
124
+ """
125
+ if not url.startswith("https://"):
126
+ raise ValueError(
127
+ f"Only HTTPS URLs are supported (got: {url.split('://')[0]}://)"
128
+ )
129
+
130
+ ctx = ssl.create_default_context()
131
+ with urllib.request.urlopen(url, timeout=_FETCH_TIMEOUT, context=ctx) as resp:
132
+ data = resp.read(_FETCH_MAX_BYTES)
133
+
134
+ # Basic binary detection — reject if null bytes present
135
+ if b"\x00" in data:
136
+ raise ValueError(f"URL returned binary content, expected markdown: {url}")
137
+
138
+ return data
@@ -17,7 +17,7 @@ from pathlib import Path
17
17
  from typing import Any
18
18
 
19
19
  sys.path.insert(0, str(Path(__file__).resolve().parent))
20
- from install_steps.project_registry import get_active_projects, prune_stale
20
+ from install_steps.project_registry import get_active_projects, prune_stale, register_project
21
21
 
22
22
 
23
23
  def _update_project(project: dict[str, Any], install_script: str, extra_args: list[str]) -> dict:
@@ -91,13 +91,17 @@ def main() -> None:
91
91
  print(f" Updating {len(projects)} registered project(s)...")
92
92
  print()
93
93
 
94
+ # --skip-register: parallel installs must NOT write to projects.json
95
+ # concurrently. We re-register sequentially after all installs complete.
96
+ parallel_args = extra_args + ["--skip-register"]
97
+
94
98
  # Run in parallel (max 8 workers — don't overwhelm the system)
95
99
  max_workers = min(len(projects), 8)
96
100
  results: list[dict] = []
97
101
 
98
102
  with ThreadPoolExecutor(max_workers=max_workers) as pool:
99
103
  futures = {
100
- pool.submit(_update_project, p, install_script, extra_args): p
104
+ pool.submit(_update_project, p, install_script, parallel_args): p
101
105
  for p in projects
102
106
  }
103
107
  for future in as_completed(futures):
@@ -118,6 +122,15 @@ def main() -> None:
118
122
  for line in result["error"].strip().split("\n"):
119
123
  print(f" ERROR: {line}")
120
124
 
125
+ # Re-register projects sequentially (safe — no concurrent writes)
126
+ for result in results:
127
+ if result["success"]:
128
+ register_project(
129
+ result["path"],
130
+ profile=result.get("profile", "standard"),
131
+ extends=result.get("extends", ""),
132
+ )
133
+
121
134
  # Summary
122
135
  passed = sum(1 for r in results if r["success"])
123
136
  failed = len(results) - passed
@@ -610,10 +610,43 @@ def validate_metadata_contracts(
610
610
  else:
611
611
  print(f" OK: tests ({actual_tests})")
612
612
 
613
+ # Cross-validate versions: package.json vs manifest.json vs plugin.json
614
+ _validate_version_sync(tk_dir, vr)
615
+
613
616
  print()
614
617
  return actual_tests
615
618
 
616
619
 
620
+ def _validate_version_sync(tk_dir: Path, vr: ValidationResult) -> None:
621
+ """Ensure package.json, manifest.json, and plugin.json versions match."""
622
+ import json as _json
623
+
624
+ version_files = {
625
+ "package.json": tk_dir / "package.json",
626
+ "manifest.json": tk_dir / "manifest.json",
627
+ "plugin.json": tk_dir / "app" / ".claude-plugin" / "plugin.json",
628
+ }
629
+
630
+ versions: dict[str, str] = {}
631
+ for name, path in version_files.items():
632
+ if path.is_file():
633
+ try:
634
+ data = _json.loads(path.read_text(encoding="utf-8"))
635
+ versions[name] = data.get("version", "")
636
+ except Exception:
637
+ pass
638
+
639
+ if len(versions) < 2:
640
+ return # Not enough files to compare (e.g., installed copy without source)
641
+
642
+ unique = set(versions.values())
643
+ if len(unique) == 1:
644
+ print(f" OK: version sync ({unique.pop()})")
645
+ else:
646
+ detail = ", ".join(f"{k}={v}" for k, v in versions.items())
647
+ vr.error(f"Version mismatch across files: {detail}")
648
+
649
+
617
650
  def validate_content_quality(tk_dir: Path, vr: ValidationResult) -> None:
618
651
  """Check content quality: name matches directory, non-empty body."""
619
652
  print()