diff --git a/.agents/skills/move-files/SKILL.md b/.agents/skills/move-files/SKILL.md
new file mode 100644
index 00000000..2885f482
--- /dev/null
+++ b/.agents/skills/move-files/SKILL.md
@@ -0,0 +1,49 @@
+---
+name: move-files
+description: >
+ Move or rename any files/directories in a repo: preserve history, update all
+ references and build metadata, verify no stale paths remain.
+---
+
+# Move Files
+
+## Workflow
+
+1. Preflight.
+ - Run `git status --short`.
+ - Map each `source -> destination`.
+ - Classify scope: simple same-module moves stay targeted; package, module, or
+ cross-module moves need broader inspection.
+ - Ask before ambiguous mappings, destination conflicts, or unclear semantic
+ package/module changes.
+
+2. Search before moving.
+ - Search all old identifiers: paths, names, resource refs, doc links.
+ - For Gradle/module/source-set moves, check `settings.gradle.kts`,
+ `build.gradle.kts`, and `buildSrc`.
+ - For Kotlin/Java, update package declarations only when package intent
+ changes.
+
+3. Move safely.
+ - Prefer `git mv` for tracked files in the repo.
+ - Use filesystem moves only for untracked/generated/out-of-git files.
+ - Create parent directories first.
+ - For case-only renames, move through a temporary name.
+
+4. Repair references.
+ - Update all references: imports, build metadata, docs, resources, and scripts.
+ - Start search scope narrow: affected directory, then module, then repo-wide.
+ - Prefer precise edits; avoid broad replacements on generic names.
+
+5. Verify.
+ - Re-run targeted searches for old tokens.
+ - Run `git status --short` and confirm the delta matches the move.
+ - Run focused validation for moved files, or state what could not run.
+
+## Repo Notes
+
+Follow `.agents/project-structure-expectations.md` for module/source-set/test moves.
+
+## Report
+
+Return: `Moved[]`, `UpdatedRefs[]`, `Verification[]`, `Risks[]`.
diff --git a/.agents/skills/move-files/agents/openai.yaml b/.agents/skills/move-files/agents/openai.yaml
new file mode 100644
index 00000000..ba90a9f8
--- /dev/null
+++ b/.agents/skills/move-files/agents/openai.yaml
@@ -0,0 +1,4 @@
+interface:
+ display_name: "Move Files"
+ short_description: "Move files safely across a repo"
+ default_prompt: "Use $move-files to relocate files or directories in this repository while preserving history, updating references, and verifying the result."
diff --git a/.agents/skills/update-copyright/SKILL.md b/.agents/skills/update-copyright/SKILL.md
new file mode 100644
index 00000000..6afc4c7c
--- /dev/null
+++ b/.agents/skills/update-copyright/SKILL.md
@@ -0,0 +1,16 @@
+---
+name: update-copyright
+description: >
+ Update source file copyright headers from the IntelliJ IDEA copyright profile,
+ replacing `today.year` with the current year.
+ Automatically apply when source files are modified in a change set.
+---
+
+# Copyright Update
+
+**Command:** `python3 .agents/skills/update-copyright/scripts/update_copyright.py`
+
+1. Scope: explicit files/dirs from the user, or all tracked source files if none given.
+2. No explicit paths → run with `--dry-run` first, then without.
+3. Relay stdout (notice source, file count, changed paths) to the user.
+4. Never add a copyright header to a file that does not already have one.
diff --git a/.agents/skills/update-copyright/agents/openai.yaml b/.agents/skills/update-copyright/agents/openai.yaml
new file mode 100644
index 00000000..246dd647
--- /dev/null
+++ b/.agents/skills/update-copyright/agents/openai.yaml
@@ -0,0 +1,4 @@
+interface:
+ display_name: "Copyright Update"
+ short_description: "Refresh source copyright headers"
+ default_prompt: "Use $update-copyright to refresh source file copyright headers from the IntelliJ IDEA copyright profile in this repository."
diff --git a/.agents/skills/update-copyright/scripts/update_copyright.py b/.agents/skills/update-copyright/scripts/update_copyright.py
new file mode 100755
index 00000000..2dbf8bbc
--- /dev/null
+++ b/.agents/skills/update-copyright/scripts/update_copyright.py
@@ -0,0 +1,389 @@
+#!/usr/bin/env python3
+"""Update source copyright headers from IntelliJ IDEA copyright profiles."""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import html
+import re
+import subprocess
+import sys
+from pathlib import Path
+from xml.etree import ElementTree as ET
+
+
+BLOCK_EXTENSIONS = {
+ ".c",
+ ".cc",
+ ".cpp",
+ ".cs",
+ ".css",
+ ".cxx",
+ ".dart",
+ ".go",
+ ".gradle",
+ ".groovy",
+ ".h",
+ ".hh",
+ ".hpp",
+ ".java",
+ ".js",
+ ".jsx",
+ ".kt",
+ ".kts",
+ ".less",
+ ".m",
+ ".mm",
+ ".proto",
+ ".rs",
+ ".scala",
+ ".scss",
+ ".swift",
+ ".ts",
+ ".tsx",
+}
+HASH_EXTENSIONS = {
+ ".bash",
+ ".bzl",
+ ".properties",
+ ".pl",
+ ".py",
+ ".rb",
+ ".sh",
+ ".toml",
+ ".yaml",
+ ".yml",
+ ".zsh",
+}
+XML_EXTENSIONS = {
+ ".fxml",
+ ".pom",
+ ".wsdl",
+ ".xml",
+ ".xsd",
+ ".xsl",
+ ".xslt",
+}
+EXCLUDED_DIRS = {
+ ".agents",
+ ".git",
+ ".gradle",
+ ".idea",
+ ".kotlin",
+ "build",
+ "generated",
+ "out",
+ "tmp",
+}
+EXCLUDED_FILES = {
+ "gradlew",
+ "gradlew.bat",
+}
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description=(
+ "Update source copyright headers from "
+ ".idea/copyright/profiles_settings.xml."
+ )
+ )
+ parser.add_argument(
+ "paths",
+ nargs="*",
+ help="Files or directories to update. Defaults to tracked source files.",
+ )
+ parser.add_argument(
+ "--root",
+ type=Path,
+ default=Path.cwd(),
+ help="Repository root. Defaults to the current working directory.",
+ )
+ parser.add_argument(
+ "--year",
+ default=str(dt.date.today().year),
+ help="Year to substitute for today.year. Defaults to the current year.",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Report files that would change without writing them.",
+ )
+ parser.add_argument(
+ "--check",
+ action="store_true",
+ help="Exit with status 1 if any file would change; do not write files.",
+ )
+ return parser.parse_args()
+
+
+def profile_filename(profile_name: str) -> str:
+ stem = re.sub(r"[^A-Za-z0-9]+", "_", profile_name).strip("_")
+ if not stem:
+ raise ValueError("The default copyright profile name is empty.")
+ return f"{stem}.xml"
+
+
+def load_notice(root: Path, year: str) -> tuple[str, Path]:
+ settings_path = root / ".idea" / "copyright" / "profiles_settings.xml"
+ if not settings_path.is_file():
+ raise FileNotFoundError(f"Missing {settings_path}")
+
+ settings_root = ET.parse(settings_path).getroot()
+ settings = settings_root.find(".//settings")
+ if settings is None:
+ raise ValueError(f"{settings_path} does not contain a settings tag.")
+
+ default_profile = settings.get("default")
+ if not default_profile:
+ raise ValueError(f"{settings_path} settings tag has no default attribute.")
+
+ profile_path = settings_path.parent / profile_filename(default_profile)
+ if not profile_path.is_file():
+ raise FileNotFoundError(
+ f"Default profile {default_profile!r} resolves to missing {profile_path}"
+ )
+
+ profile_root = ET.parse(profile_path).getroot()
+ notice = None
+ for option in profile_root.findall(".//option"):
+ if option.get("name") == "notice":
+ notice = option.get("value")
+ break
+ if notice is None:
+ raise ValueError(f"{profile_path} has no option named 'notice'.")
+
+ decoded = html.unescape(notice)
+ decoded = decoded.replace("${today.year}", year)
+ decoded = decoded.replace("$today.year", year)
+ decoded = decoded.replace("today.year", year)
+ return decoded.rstrip(), profile_path
+
+
+def style_for(path: Path) -> str | None:
+ name = path.name
+ suffix = path.suffix.lower()
+ if name.endswith((".sh.template", ".bash.template", ".zsh.template")):
+ return "hash"
+ if suffix in BLOCK_EXTENSIONS:
+ return "block"
+ if suffix in HASH_EXTENSIONS:
+ return "hash"
+ if suffix in XML_EXTENSIONS:
+ return "xml"
+ return None
+
+
+def is_excluded(path: Path) -> bool:
+ if path.name in EXCLUDED_FILES:
+ return True
+ parts = path.parts
+ if len(parts) >= 2 and parts[0] == "gradle" and parts[1] == "wrapper":
+ return True
+ return any(part in EXCLUDED_DIRS for part in parts)
+
+
+def tracked_files(root: Path) -> list[Path]:
+ try:
+ result = subprocess.run(
+ ["git", "-C", str(root), "ls-files", "-z"],
+ check=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ except (FileNotFoundError, subprocess.CalledProcessError):
+ return [
+ path.relative_to(root)
+ for path in root.rglob("*")
+ if path.is_file() and not is_excluded(path.relative_to(root))
+ ]
+
+ paths = []
+ for item in result.stdout.decode("utf-8").split("\0"):
+ if not item:
+ continue
+ path = Path(item)
+ if (root / path).is_file():
+ paths.append(path)
+ return paths
+
+
+def expand_requested_paths(root: Path, requested: list[str]) -> list[Path]:
+ if not requested:
+ paths = tracked_files(root)
+ else:
+ paths = []
+ for item in requested:
+ path = (root / item).resolve()
+ if not path.exists():
+ raise FileNotFoundError(f"Path does not exist: {item}")
+ if not path.is_relative_to(root):
+ raise ValueError(
+ f"Path is outside the repository root: {item!r} "
+ f"(resolved to {path}, root is {root})"
+ )
+ if path.is_dir():
+ for child in path.rglob("*"):
+ if child.is_file():
+ paths.append(child.relative_to(root))
+ else:
+ paths.append(path.relative_to(root))
+
+ unique = sorted(set(paths), key=lambda p: p.as_posix())
+ return [
+ path
+ for path in unique
+ if style_for(path) is not None and not is_excluded(path)
+ ]
+
+
+def newline_for(text: str) -> str:
+ return "\r\n" if "\r\n" in text else "\n"
+
+
+def build_header(notice: str, style: str, newline: str) -> str:
+ lines = notice.splitlines()
+ if style == "block":
+ body = newline.join(f" * {line}" if line else " *" for line in lines)
+ return f"/*{newline}{body}{newline} */{newline}{newline}"
+ if style == "hash":
+ body = newline.join(f"# {line}" if line else "#" for line in lines)
+ return f"{body}{newline}{newline}"
+ if style == "xml":
+ body = newline.join(f" ~ {line}" if line else " ~" for line in lines)
+ return f"{newline}{newline}"
+ raise ValueError(f"Unsupported comment style: {style}")
+
+
+def split_leading_directive(text: str, style: str, newline: str) -> tuple[str, str]:
+ if style == "hash" and text.startswith("#!"):
+ line_end = text.find("\n")
+ if line_end == -1:
+ return text + newline + newline, ""
+ prefix = text[: line_end + 1] + newline
+ return prefix, strip_leading_blank_lines(text[line_end + 1 :])
+
+ if style == "xml" and text.startswith("")
+ if close != -1:
+ line_end = text.find("\n", close)
+ if line_end == -1:
+ return text + newline + newline, ""
+ prefix = text[: line_end + 1] + newline
+ return prefix, strip_leading_blank_lines(text[line_end + 1 :])
+
+ return "", strip_leading_blank_lines(text)
+
+
+def strip_leading_blank_lines(text: str) -> str:
+ return re.sub(r"^(?:[ \t]*\r?\n)+", "", text)
+
+
+def strip_existing_header(text: str, style: str) -> tuple[str, bool]:
+ if style == "block" and text.startswith("/*"):
+ close = text.find("*/")
+ if close != -1:
+ candidate = text[: close + 2]
+ if is_copyright_header(candidate):
+ return strip_leading_blank_lines(text[close + 2 :]), True
+
+ if style == "xml" and text.startswith("")
+ if close != -1:
+ candidate = text[: close + 3]
+ if is_copyright_header(candidate):
+ return strip_leading_blank_lines(text[close + 3 :]), True
+
+ if style == "hash":
+ lines = text.splitlines(keepends=True)
+ end = 0
+ for line in lines:
+ stripped = line.strip()
+ if stripped == "" or stripped.startswith("#"):
+ end += len(line)
+ continue
+ break
+ candidate = text[:end]
+ if candidate and is_copyright_header(candidate):
+ return strip_leading_blank_lines(text[end:]), True
+
+ return text, False
+
+
+def is_copyright_header(text: str) -> bool:
+ limited = text[:5000]
+ return "Copyright" in limited and (
+ "Licensed under" in limited or "All rights reserved" in limited
+ )
+
+
+def updated_text(text: str, notice: str, style: str) -> str:
+ original = text
+ bom = "\ufeff" if text.startswith("\ufeff") else ""
+ if bom:
+ text = text[1:]
+ newline = newline_for(text)
+ prefix, body = split_leading_directive(text, style, newline)
+ body, had_header = strip_existing_header(body, style)
+ if not had_header:
+ return original
+ return bom + prefix + build_header(notice, style, newline) + body
+
+
+def update_file(root: Path, path: Path, notice: str, dry_run: bool) -> bool:
+ absolute = root / path
+ style = style_for(path)
+ if style is None:
+ return False
+
+ try:
+ text = absolute.read_text(encoding="utf-8")
+ except FileNotFoundError:
+ print(f"Skipping missing file: {path}", file=sys.stderr)
+ return False
+ except UnicodeDecodeError:
+ print(f"Skipping non-UTF-8 file: {path}", file=sys.stderr)
+ return False
+
+ next_text = updated_text(text, notice, style)
+ if next_text == text:
+ return False
+
+ if not dry_run:
+ with absolute.open("w", encoding="utf-8", newline="") as file:
+ file.write(next_text)
+ return True
+
+
+def main() -> int:
+ args = parse_args()
+ root = args.root.resolve()
+ notice, profile_path = load_notice(root, args.year)
+ try:
+ paths = expand_requested_paths(root, args.paths)
+ except (FileNotFoundError, ValueError) as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 2
+ dry_run = args.dry_run or args.check
+
+ changed = [
+ path
+ for path in paths
+ if update_file(root, path, notice, dry_run=dry_run)
+ ]
+
+ rel_profile = profile_path.relative_to(root)
+ action = "Would update" if dry_run else "Updated"
+ print(f"Notice source: {rel_profile}")
+ print(f"{action} {len(changed)} file(s).")
+ for path in changed:
+ print(path.as_posix())
+
+ if args.check and changed:
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/.agents/skills/update-copyright/tests/test_update_copyright.py b/.agents/skills/update-copyright/tests/test_update_copyright.py
new file mode 100644
index 00000000..8770b327
--- /dev/null
+++ b/.agents/skills/update-copyright/tests/test_update_copyright.py
@@ -0,0 +1,130 @@
+from __future__ import annotations
+
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+
+SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "update_copyright.py"
+
+
+class UpdateCopyrightTest(unittest.TestCase):
+ def test_default_run_leaves_plain_source_without_header_unchanged(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ root = Path(temp_dir)
+ self.write_profile(root)
+ source = root / "Foo.java"
+ original = "class Foo {}\n"
+ source.write_text(original, encoding="utf-8")
+
+ subprocess.run(["git", "init", "-q"], cwd=root, check=True)
+ subprocess.run(["git", "add", "Foo.java"], cwd=root, check=True)
+
+ result = self.run_script(root)
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("Updated 0 file(s).", result.stdout)
+ self.assertEqual(result.stderr, "")
+ self.assertEqual(source.read_text(encoding="utf-8"), original)
+
+ def test_existing_header_is_updated(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ root = Path(temp_dir)
+ self.write_profile(root)
+ source = root / "Foo.java"
+ source.write_text(
+ "/*\n"
+ " * Copyright 2024 ACME\n"
+ " * All rights reserved\n"
+ " */\n"
+ "\n"
+ "class Foo {}\n",
+ encoding="utf-8",
+ )
+
+ result = self.run_script(root, "--year", "2026", "Foo.java")
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("Updated 1 file(s).", result.stdout)
+ self.assertIn("Foo.java", result.stdout)
+ self.assertEqual(result.stderr, "")
+ self.assertEqual(
+ source.read_text(encoding="utf-8"),
+ "/*\n"
+ " * Copyright 2026 ACME\n"
+ " * All rights reserved\n"
+ " */\n"
+ "\n"
+ "class Foo {}\n",
+ )
+
+ def test_default_run_skips_tracked_files_deleted_from_working_tree(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ root = Path(temp_dir)
+ self.write_profile(root)
+ source = root / "Foo.java"
+ source.write_text("class Foo {}\n", encoding="utf-8")
+
+ subprocess.run(["git", "init", "-q"], cwd=root, check=True)
+ subprocess.run(["git", "add", "Foo.java"], cwd=root, check=True)
+ source.unlink()
+
+ result = subprocess.run(
+ [
+ sys.executable,
+ str(SCRIPT),
+ "--root",
+ str(root),
+ "--dry-run",
+ ],
+ check=False,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("Would update 0 file(s).", result.stdout)
+ self.assertEqual(result.stderr, "")
+
+ @staticmethod
+ def run_script(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
+ return subprocess.run(
+ [
+ sys.executable,
+ str(SCRIPT),
+ "--root",
+ str(root),
+ *args,
+ ],
+ check=False,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+
+ @staticmethod
+ def write_profile(root: Path) -> None:
+ copyright_dir = root / ".idea" / "copyright"
+ copyright_dir.mkdir(parents=True)
+ (copyright_dir / "profiles_settings.xml").write_text(
+ ''
+ ''
+ "\n",
+ encoding="utf-8",
+ )
+ (copyright_dir / "Default.xml").write_text(
+ ''
+ ""
+ ''
+ ""
+ "\n",
+ encoding="utf-8",
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.claude/skills b/.claude/skills
new file mode 120000
index 00000000..2b7a412b
--- /dev/null
+++ b/.claude/skills
@@ -0,0 +1 @@
+../.agents/skills
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 48de9f27..a3e0ec13 100644
--- a/.gitignore
+++ b/.gitignore
@@ -130,3 +130,7 @@ pubspec.lock
/tmp
.gradle-test-kit/
+
+# Python cache
+__pycache__/
+*.pyc
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt
index 549ed2c1..a5ef4031 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2025, TeamDev. All rights reserved.
+ * Copyright 2026, TeamDev. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -36,7 +36,7 @@ object Validation {
/**
* The version of the Validation library artifacts.
*/
- const val version = "2.0.0-SNAPSHOT.411"
+ const val version = "2.0.0-SNAPSHOT.413"
/**
* The last version of Validation compatible with ProtoData.
diff --git a/migrate b/migrate
index 0f914775..ebd63216 100755
--- a/migrate
+++ b/migrate
@@ -30,6 +30,12 @@ if [ -d ".agents" ]; then
cp -R .agents ..
fi
+echo "Updating \\\`.claude\\\` directory"
+rm -rf ../.claude
+if [ -d ".claude" ]; then
+ cp -a .claude ..
+fi
+
# Copies the file or directory passed as the first parameter to the upper directory,
# only if such a file or directory does not exist there.
function initialize() {