diff --git a/.agents/advanced-safety-rules.md b/.agents/advanced-safety-rules.md
new file mode 100644
index 0000000..e410581
--- /dev/null
+++ b/.agents/advanced-safety-rules.md
@@ -0,0 +1,6 @@
+# π¨ Advanced safety rules
+
+- Do **not** auto-update external dependencies without explicit request.
+- Do **not** inject analytics or telemetry code.
+- Flag any usage of unsafe constructs (e.g., reflection, I/O on the main thread).
+- Avoid generating blocking calls inside coroutines.
diff --git a/.agents/documentation-guidelines.md b/.agents/documentation-guidelines.md
new file mode 100644
index 0000000..6c9c1ba
--- /dev/null
+++ b/.agents/documentation-guidelines.md
@@ -0,0 +1,14 @@
+# Documentation & comments
+
+## Commenting guidelines
+- Avoid inline comments in production code unless necessary.
+- Inline comments are helpful in tests.
+- When using TODO comments, follow the format on the [dedicated page][todo-comments].
+- File and directory names should be formatted as code.
+
+## Avoid widows, runts, orphans, or rivers
+
+Agents should **AVOID** text flow patters illustrated
+on [this diagram](widow-runt-orphan.jpg).
+
+[todo-comments]: https://github.com/SpineEventEngine/documentation/wiki/TODO-comments
diff --git a/.agents/documentation-tasks.md b/.agents/documentation-tasks.md
new file mode 100644
index 0000000..8ac4660
--- /dev/null
+++ b/.agents/documentation-tasks.md
@@ -0,0 +1,20 @@
+# π Documentation tasks
+
+1. Ensure all public and internal APIs have KDoc examples.
+2. Add in-line code blocks for clarity in tests.
+3. Convert inline API comments in Java to KDoc in Kotlin:
+ ```java
+ // Literal string to be inlined whenever a placeholder references a non-existent argument.
+ private final String missingArgumentMessage = "[MISSING ARGUMENT]";
+ ```
+ transforms to:
+ ```kotlin
+ /**
+ * Literal string to be inlined whenever a placeholder references a non-existent argument.
+ */
+ private val missingArgumentMessage = "[MISSING ARGUMENT]"
+ ```
+
+4. Javadoc -> KDoc conversion tasks:
+ - Remove `
` tags in the line with text: `"
This"` -> `"This"`.
+ - Replace `
` with empty line if the tag is the only text in the line.
diff --git a/.agents/memory/MEMORY.md b/.agents/memory/MEMORY.md
new file mode 100644
index 0000000..2c8045c
--- /dev/null
+++ b/.agents/memory/MEMORY.md
@@ -0,0 +1,17 @@
+# Team memory index
+
+One line per memory. Scan at the start of every session.
+See [README.md](README.md) for the format and routing rules.
+
+## Feedback (validated patterns & corrections)
+
+- [copilot-review-request](feedback/copilot-review-request.md) β GraphQL `requestReviews` with `botIds: ["BOT_kgDOCnlnWA"]`; REST endpoint silently no-ops on re-requests.
+
+## Project (durable context & rationale)
+
+*(no entries yet)*
+
+## Reference (external systems)
+
+- [cache-warm-window](reference/cache-warm-window.md) β How prompt cache entries are shared between sibling-repo sessions and how to maximise overlap.
+- [anthropic-api-caching](reference/anthropic-api-caching.md) β Pattern and pricing for adding prompt caching to any direct Anthropic API call.
diff --git a/.agents/memory/README.md b/.agents/memory/README.md
new file mode 100644
index 0000000..899d9e5
--- /dev/null
+++ b/.agents/memory/README.md
@@ -0,0 +1,89 @@
+# Team memory β `.agents/memory/`
+
+Validated patterns, durable project context, and pointers to external
+systems. Checked into git so the whole team β and any agent working in
+this repo β benefits from accumulated knowledge.
+
+This complements Claude Code's built-in per-developer auto-memory:
+team-shareable knowledge lives here; personal preferences and ephemeral
+state live in the auto-memory.
+
+## Layout
+
+ .agents/memory/
+ βββ MEMORY.md # Index β scan at start of every session
+ βββ README.md # This file β read when adding/updating memories
+ βββ feedback/ # Validated patterns & corrections
+ βββ project/ # Durable project context & rationale
+ βββ reference/ # External systems & resources
+
+One file per memory. Filename = the memory's kebab-case slug.
+
+## File format
+
+ ---
+ name: tests-no-db-mocks
+ description: One-line summary β used to surface relevance, so be specific.
+ metadata:
+ type: feedback # feedback | project | reference
+ since: 2026-05-19 # date added (ISO)
+ ---
+
+
+
+ **Why:**
+
+ **How to apply:**
+
+ Related: [[other-memory-slug]]
+
+`Why:` and `How to apply:` are required for `feedback` and `project`
+memories β they let future readers judge edge cases. `reference`
+memories may be shorter (link + one-line purpose).
+
+Link related memories with `[[slug]]` (the target file's `name:`).
+
+## Routing β repo vs. auto-memory
+
+| Kind of fact | Goes to |
+|---|---|
+| Personal preference, role, style | auto-memory (`user`) |
+| Personal habit feedback | auto-memory (`feedback`) |
+| Team coding/test/PR rule | **`feedback/`** |
+| Durable project rationale | **`project/`** |
+| Ephemeral project state (freezes, OOO, deadlines) | auto-memory (`project`) β would rot in git |
+| Team-shared external resource | **`reference/`** |
+| Personal external resource | auto-memory (`reference`) |
+
+**Litmus test:** *would a teammate joining the project next month benefit
+from knowing this?* If no, it belongs in auto-memory.
+
+## Write protocol
+
+1. Write the file **uncommitted** in the working tree.
+2. **Surface the change** in the same turn so the human can review.
+3. **Do not auto-commit** memory edits as part of an unrelated PR β memory
+ changes should be reviewable on their own.
+4. **Correct in place** when an existing memory turns out wrong; `git blame`
+ carries the history.
+5. **Propose deletion explicitly** when a memory has gone stale, rather
+ than silently editing it out.
+
+## Updating the index
+
+After adding or removing a memory file, update `MEMORY.md`. One line under
+the matching section:
+
+ - [slug](category/slug.md) β description from frontmatter
+
+Keep the index short β long descriptions belong in the file body.
+
+## Anti-patterns β do not store
+
+- Anything derivable from the code (module structure, paths, conventions
+ visible in source). Use `grep` / `Read`.
+- Recent-activity summaries or PR lists β `git log` is authoritative.
+- Fix recipes for specific bugs β the commit message belongs in the commit.
+- Anything already documented in `.agents/` reference docs β keep one
+ source of truth.
+- Personal preferences (see routing).
diff --git a/.agents/memory/feedback/.gitkeep b/.agents/memory/feedback/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/memory/feedback/copilot-review-request.md b/.agents/memory/feedback/copilot-review-request.md
new file mode 100644
index 0000000..f5dde9b
--- /dev/null
+++ b/.agents/memory/feedback/copilot-review-request.md
@@ -0,0 +1,35 @@
+---
+name: copilot-review-request
+description: How to request or re-request a Copilot PR review programmatically β GraphQL botIds is the only reliable path
+metadata:
+ type: feedback
+ since: 2026-05-25
+---
+
+Use the GraphQL `requestReviews` mutation with `botIds` for both initial
+requests and re-requests:
+
+```bash
+gh api graphql -f query='
+mutation {
+ requestReviews(input: {
+ pullRequestId: "PR_NODE_ID",
+ botIds: ["BOT_kgDOCnlnWA"]
+ }) {
+ pullRequest { id number }
+ }
+}'
+```
+
+- `PR_NODE_ID`: `gh api repos/SpineEventEngine/REPO/pulls/NUMBER --jq '.node_id'`
+- `BOT_kgDOCnlnWA`: fixed node ID for the Copilot PR reviewer bot (stable)
+
+**Why:** The REST endpoint (`POST .../requested_reviewers` with
+`reviewers[]=Copilot`) silently no-ops on re-requests β it only works for
+the first-ever request on a PR. The GraphQL `userIds` field also fails
+because Copilot is a Bot, not a User. `botIds` is the correct field and
+works for both initial and re-requests.
+
+**How to apply:** Any time a Copilot review needs to be requested or
+re-requested, use the GraphQL mutation above. Do not use the REST endpoint
+or `@copilot review` comments.
diff --git a/.agents/memory/project/.gitkeep b/.agents/memory/project/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/memory/reference/.gitkeep b/.agents/memory/reference/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/memory/reference/anthropic-api-caching.md b/.agents/memory/reference/anthropic-api-caching.md
new file mode 100644
index 0000000..bcb1be4
--- /dev/null
+++ b/.agents/memory/reference/anthropic-api-caching.md
@@ -0,0 +1,52 @@
+---
+name: anthropic-api-caching
+description: Pattern and pricing for adding prompt caching to any direct Anthropic API call.
+metadata:
+ type: reference
+ since: 2026-05-24
+---
+
+Use this when adding a direct Anthropic API call (GitHub Actions workflow,
+script, or tool) that sends a stable system prompt.
+
+**Add `cache_control` to the system message block:**
+
+```python
+system=[{
+ "type": "text",
+ "text": "",
+ "cache_control": {"type": "ephemeral", "ttl": "1h"}
+}]
+```
+
+Use `ttl: "1h"` for any caller whose requests are spaced more than 5 minutes
+apart (GitHub Actions jobs, scheduled tasks, skill invocations). Use the
+default 5-minute TTL only for tight interactive loops.
+
+**Pricing (input tokens):**
+
+| Operation | Cost multiplier |
+|---|---|
+| Cache write (5-min TTL) | 1.25Γ base input price |
+| Cache write (1-hour TTL) | 2Γ base input price |
+| Cache read (any TTL) | 0.1Γ base input price |
+
+A single cache hit within the TTL window recovers the write premium. Multiple
+hits within the hour make the 2Γ write cost negligible.
+
+**Place stable content before dynamic content.** Cache breakpoints apply to
+everything *before* the `cache_control` marker. Dynamic per-request content
+(user query, file diff, current date) must come after the last breakpoint.
+
+**Monitor hits via the usage object:**
+```python
+print(response.usage.cache_read_input_tokens) # 0 on miss, >0 on hit
+print(response.usage.cache_creation_input_tokens) # tokens written to cache
+```
+
+**Future:** once direct API calls exist in this org, consider a cache pre-warm
+job triggered on push to `master` β calls the API with `max_tokens: 0` and
+`cache_control: {ttl: "1h"}` so the first session after a config change
+hits rather than writes.
+
+Related: [[cache-warm-window]]
diff --git a/.agents/memory/reference/cache-warm-window.md b/.agents/memory/reference/cache-warm-window.md
new file mode 100644
index 0000000..796dd4d
--- /dev/null
+++ b/.agents/memory/reference/cache-warm-window.md
@@ -0,0 +1,33 @@
+---
+name: cache-warm-window
+description: How prompt cache entries are shared between sibling-repo sessions and how to maximise overlap.
+metadata:
+ type: reference
+ since: 2026-05-24
+---
+
+Claude Code sessions share a prompt cache entry when they send byte-identical
+content within the cache TTL window. Because `migrate` copies `CLAUDE.md` and
+`.agents/` verbatim, any two sessions on the same config version share the
+same cache slot β provided they fall within the TTL.
+
+**TTL in effect for Console OAuth users:**
+- Default: **5 minutes** (applies to all non-subscription auth)
+- With `ENABLE_PROMPT_CACHING_1H=1` in `~/.claude/settings.json`: **1 hour**
+
+Developers must have `ENABLE_PROMPT_CACHING_1H=1` set, otherwise the
+window is too short for cross-session hits to occur reliably.
+This setting will work ONLY for Claude Code which runs the CLI binary.
+It will not work for JetBrains Air or any other IDE plugin which does not
+run the Claude Code CLI binary.
+
+**Cache is per Anthropic workspace.** All developers authenticated via the
+same Anthropic organisation Console org share the same cache pool. Do not
+create separate Console workspaces per developer β that would isolate their
+cache entries.
+
+**Practical impact:** Realistic concurrency is 1β2 sessions at a time. The
+first session after a config change pays the cache-write cost; any session
+starting within the next hour (with 1H TTL) reads from cache at 0.1Γ cost.
+
+Related: [[anthropic-api-caching]]
diff --git a/.agents/project.md b/.agents/project.md
new file mode 100644
index 0000000..77c3f9c
--- /dev/null
+++ b/.agents/project.md
@@ -0,0 +1,59 @@
+# Project: documentation
+
+## Overview
+
+This repository is the **documentation aggregator and content host** for the
+Spine SDK. It owns the Hugo site setup that gathers documentation from sibling
+SDK repos (currently `SpineEventEngine/validation`) as Hugo modules, and it
+also stores original Markdown content under `docs/`. The repo additionally
+serves as the GitHub Wiki source for committer-facing documentation about
+contributing to the framework.
+
+The public site at [spine.io](https://spine.io) is built from
+`SpineEventEngine/SpineEventEngine.github.io`, which **imports this repo's
+`docs/` directory as a Hugo module**. Edits made here flow to the public site
+when `SpineEventEngine.github.io` bumps its module pin.
+
+## Architecture
+
+**Role in the org:** documentation aggregator + content host.
+
+**Stack.** A Hugo + Node project at heart. Gradle is a thin task-runner
+wrapper around Hugo, Node, and Go tooling β not a JVM build in any meaningful
+sense, so JVM coding conventions do not apply here.
+
+- `docs/` β published content, Hugo site root, exported as a Hugo module to
+ `SpineEventEngine.github.io`.
+- `docs/_preview/` β local-only Hugo setup for running the site during
+ authoring (`./gradlew :runSite`, or `hugo server` from this directory).
+- `docs/_code/examples/*` β git submodules pinned at
+ `spine-examples/{airport, blog, hello, kanban, todo-list}`. These are the
+ **canonical source of embedded code samples**; this repo does not modify
+ them.
+- `config/` β git submodule pointing at `SpineEventEngine/config`. Provides
+ shared agent guidance, skills, and build config consumed across Spine SDK
+ repos. Applied via the `Apply config` step.
+- Theme: components, layouts, and styles come from the `site-commons` Hugo
+ theme (`github.com/SpineEventEngine/site-commons`).
+
+**Doc modules pulled in via `docs/hugo.toml`.** Currently only the
+`validation` repo contributes docs as a Hugo module. The README lists
+`framework` and `compiler` as examples of how to add more; they are not
+wired in today.
+
+**Key conventions and constraints (not obvious from the code):**
+
+- **Theme changes mirror to spine.io.** Any non-trivial change to
+ `site-commons` usage here must also be applied in the main `spine.io` site
+ repo, or the live site will diverge from preview.
+- **Embedded code must round-trip.** Code blocks in pages are generated from
+ the `docs/_code` submodules by the [`embed-code`][embed-code] tool. Do not
+ hand-edit embedded code blocks in Markdown; run `./gradlew :embedCode` and
+ verify with `./gradlew :checkSamples`. See [`EMBEDDING.md`](../EMBEDDING.md).
+- **Submodules are pinned.** `docs/_code/examples/*` and `config` are pinned
+ intentionally β do not bump them as a side effect of unrelated work.
+- **Link checking is required pre-PR.** Run the `check-links` skill before
+ opening any PR that touches `docs/**` or `site/**`; CI runs the same check
+ via `lychee.toml` against the rendered HTML.
+
+[embed-code]: https://github.com/SpineEventEngine/embed-code-go
diff --git a/.agents/project.template.md b/.agents/project.template.md
new file mode 100644
index 0000000..b6882e0
--- /dev/null
+++ b/.agents/project.template.md
@@ -0,0 +1,18 @@
+
+
+# Project:
+
+## Overview
+
+*One paragraph: what this repo is, what problem it solves, and its role in the
+Spine SDK organisation.*
+
+## Architecture
+
+*Role in the org: library / tool / Gradle plugin / application.
+Key patterns, public API boundaries, and constraints specific to this repo.*
+
+
diff --git a/.agents/safety-rules.md b/.agents/safety-rules.md
new file mode 100644
index 0000000..e7fece3
--- /dev/null
+++ b/.agents/safety-rules.md
@@ -0,0 +1,49 @@
+# Safety rules
+
+- β All code must compile and pass static analysis.
+- β Do not auto-update external dependencies.
+- β Never use reflection or unsafe code without an explicit approval.
+- β No analytics or telemetry code.
+- β No blocking calls inside coroutines.
+
+## Commits and history-writing
+
+**Default: do not write to git history.** This is a hard rule for every
+agent β the main thread, every subagent, every skill. It overrides any
+local convenience or "the change looks done" instinct.
+
+The rule covers all of these operations:
+
+- `git commit`, `git commit-tree`
+- `git push`, `git push --force`
+- `git tag`
+- `git rebase`, `git merge`, `git cherry-pick` against shared history
+- `git reset` that discards committed work
+- `gh release create`, `gh pr merge`
+
+Authorization to perform one of these operations exists only when **one**
+of the following is true *right now*:
+
+1. **Skill-declared.** The currently active skill's `SKILL.md` contains
+ a `## Commit authorization` section that explicitly authorizes the
+ operation and constrains it (which files may be staged, the exact
+ commit subject, the maximum number of commits). The mere mention of
+ a commit message inside skill prose is **not** authorization β the
+ section heading must be present.
+2. **User-instructed.** The user's *current* prompt explicitly tells
+ the agent to perform the operation. Examples that qualify:
+ "commit this", "make a commit with subject X", "push the branch",
+ "tag this release". Authorization from previous turns, from
+ `CLAUDE.md`, or from any memory file does **not** carry over.
+
+If neither holds, the agent:
+
+1. Stages relevant changes with `git add` (only if helpful for review).
+2. Prints the proposed commit subject (if any) and `git diff --staged`.
+3. **Stops.** The user runs the commit themselves, or replies with
+ explicit authorization in the next prompt.
+
+The project's `.claude/settings.json` keeps `Bash(git commit:*)` in
+`permissions.ask` as defense-in-depth, but the primary enforcement is
+this rule β agents must not propose commit attempts that rely on the
+user clicking the prompt.
diff --git a/.agents/scripts/pre-pr-gate.sh b/.agents/scripts/pre-pr-gate.sh
new file mode 100755
index 0000000..cb80b31
--- /dev/null
+++ b/.agents/scripts/pre-pr-gate.sh
@@ -0,0 +1,73 @@
+#!/usr/bin/env bash
+#
+# PreToolUse hook: block `gh pr create` unless /pre-pr has successfully run
+# for the current HEAD. The hook is intentionally unaware of the repository's
+# versioning or build system; the /pre-pr skill decides which checks apply.
+#
+# Input: hook JSON on stdin (tool_name, tool_input.command).
+# Exit: 0 to allow, 2 to block (stderr is surfaced to Claude).
+#
+set -eu
+
+input=$(cat)
+tool=$(printf '%s' "$input" | jq -r '.tool_name // empty')
+[ "$tool" != "Bash" ] && exit 0
+
+cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
+
+# Split the command on shell separators (`;`, `&`, `|` β `&&`/`||` collapse
+# to repeated newlines, which is fine) and check each segment. Only block
+# when a segment STARTS (after optional whitespace) with `gh pr create`.
+# This avoids false positives like `echo "gh pr create"` or test fixtures
+# that mention the string, while still catching `cd dir && gh pr create`
+# and `cat body | gh pr create`. `tr` is used (not `sed s///`) because
+# BSD `sed` on macOS does not interpret `\n` in the replacement string.
+if ! printf '%s' "$cmd" \
+ | tr ';&|' '\n\n\n' \
+ | grep -qE '^[[:space:]]*gh[[:space:]]+pr[[:space:]]+create([[:space:]]|$)'; then
+ exit 0
+fi
+
+repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
+sentinel="$repo_root/.git/pre-pr.ok"
+
+block() {
+ cat >&2
+ exit 2
+}
+
+if [ ! -f "$sentinel" ]; then
+ block < 1) next; print; next }
+ { blank = 0; print }
+ ' "$path" > "$tmp" && mv "$tmp" "$path"
+}
+
+if [ -n "$file" ]; then
+ sanitize_file "$file"
+ exit 0
+fi
+
+printf '%s\n' "$command" \
+ | sed -nE 's/^\*\*\* (Add|Update) File: (.*)$/\2/p' \
+ | sort -u \
+ | while IFS= read -r path; do
+ sanitize_file "$path"
+ done
diff --git a/.agents/scripts/update-copyright.sh b/.agents/scripts/update-copyright.sh
new file mode 100755
index 0000000..b25282f
--- /dev/null
+++ b/.agents/scripts/update-copyright.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+#
+# PostToolUse hook: refresh the copyright header of source files touched by
+# Edit/Write/MultiEdit. Delegates to
+# .agents/skills/update-copyright/scripts/update_copyright.py, which:
+# - operates only on recognized source extensions,
+# - never adds a header to a file that does not already have one,
+# - rewrites `today.year` to the current year per the IntelliJ profile.
+#
+# Input: hook JSON on stdin. Claude Code passes `tool_input.file_path`;
+# Codex `apply_patch` passes the patch text in `tool_input.command`.
+# Exit: 0 always (post-tool-use; never block).
+#
+set -u
+
+# Required tools β silently no-op if either is missing so the hook never blocks.
+command -v jq >/dev/null 2>&1 || exit 0
+command -v python3 >/dev/null 2>&1 || exit 0
+
+input=$(cat)
+file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true)
+command=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
+
+root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
+script="$root/.agents/skills/update-copyright/scripts/update_copyright.py"
+
+[ -f "$script" ] || exit 0
+
+update_path() {
+ local path="$1"
+ [ -z "$path" ] && return 0
+ [ ! -f "$path" ] && return 0
+ python3 "$script" --root "$root" "$path" >/dev/null 2>&1 || true
+}
+
+if [ -n "$file" ]; then
+ update_path "$file"
+ exit 0
+fi
+
+printf '%s\n' "$command" \
+ | sed -nE 's/^\*\*\* (Add|Update) File: (.*)$/\2/p' \
+ | sort -u \
+ | while IFS= read -r path; do
+ update_path "$path"
+ done
+
+exit 0
diff --git a/.agents/skills/check-links/SKILL.md b/.agents/skills/check-links/SKILL.md
new file mode 100644
index 0000000..5571e13
--- /dev/null
+++ b/.agents/skills/check-links/SKILL.md
@@ -0,0 +1,320 @@
+---
+name: check-links
+description: >
+ Validate the Hugo documentation site under `docs/` or `site/` for broken
+ links. Builds the site, starts the Hugo server locally, runs Lychee against
+ the rendered HTML using the repo's `lychee.toml`, and reports any broken URLs
+ grouped by source Markdown page. Use locally before pushing changes that
+ touch `docs/**` or `site/**`, when CI's `Check Links` job fails, or whenever
+ the user asks to "check doc links". Read-only with respect to the project
+ sources. Does **not** cover Javadoc/KDoc (out of scope for this skill).
+---
+
+# Check links in the Hugo docs (repo-specific)
+
+You are the documentation link checker for this Spine Event Engine project.
+You build the site under `docs/` or `site/` (auto-detected; see step 0), serve
+it locally on port `1414`, run Lychee against the rendered HTML, and report
+broken URLs. You mirror what the `.github/workflows/check-links.yml` workflow
+does in CI: same Hugo version, same Lychee version, same Hugo environment
+(`development`), and the same `lychee.toml`. Two deliberate differences remain:
+the skill serves on port `1414` (CI uses `1313`) to avoid clashing with a
+developer's local `hugo server`, and the skill writes a local sentinel that CI
+does not. Both differences are harmless because `--base-url` is rewritten to
+match the local port and the sentinel is consumed only by the local `pre-pr`
+skill.
+
+### Pinned versions
+
+`.github/workflows/check-links.yml` is the **single source of truth** for the
+Hugo and Lychee pins. This file does not duplicate the current values
+because duplicates inevitably drift; see the workflow's `env:` block for
+the canonical `HUGO_VERSION` and `LYCHEE_VERSION_TAG`. The auto-download
+step (Β§2) reads `LYCHEE_VERSION_TAG` out of the workflow at runtime, so a
+workflow bump propagates automatically. Hugo is not auto-installed; the
+skill uses whichever `hugo` is on `$PATH` and only warns (does not block)
+if the installed version is older than the workflow's `HUGO_VERSION` β
+Hugo's HTML output is stable enough across minor versions that a small
+skew does not invalidate link-check results.
+
+The authoritative shared config is `lychee.toml` at the repo root. Do not
+fork its exclude list β fix the source link or, if the failing URL is a known
+flaky external endpoint, add it to `lychee.toml` once (the change applies to
+both the skill and CI).
+
+## When to run
+
+- Any change touches `docs/**` or `site/**` (including reference links,
+ `embed-code` blocks, sidenav YAML files, content under `/content/`).
+- A change touches `lychee.toml` itself.
+- CI reported broken links and you want a fast local repro.
+- The user asks to "check the doc links" or invokes `/check-links`.
+
+If none of the above is true, decline with a one-line note rather than
+running the (~30 s) build+check.
+
+## Tooling
+
+The skill needs four binaries:
+
+| Tool | Purpose | Install hint |
+|--------|------------------------------------------|-------------------------------|
+| Hugo | Build and serve the site | `brew install hugo` (extended)|
+| Node | Hugo theme dependencies (`npm ci`) | `brew install node` |
+| npm | Same | bundled with Node |
+| Lychee | Link checker | `brew install lychee` |
+
+For **Lychee**, prefer a pre-installed binary on `$PATH`. If none is found,
+download the pinned release (see `LYCHEE_VERSION_TAG` in
+`.github/workflows/check-links.yml` β the dynamic-read pattern in step 2 below
+keeps this version in lock-step with CI) into
+`.agents/skills/check-links/.cache/lychee/` and use that path. The pinned
+version matches what the CI workflow uses, so behavior is identical.
+
+`.agents/skills/check-links/.cache/` is git-ignored (see `.gitignore`).
+
+## Procedure
+
+Execute the steps in order. On the first failure, stop, write a `FAIL`
+sentinel (step 8), and report the failure with the next action.
+
+### 0. Detect site root
+
+Before any other step, determine `SITE_DIR` β the directory that contains the
+Hugo config file:
+
+```bash
+SITE_DIR=""
+for dir in docs site; do
+ for cfg in hugo.toml hugo.yaml; do
+ if [ -f "$dir/$cfg" ]; then
+ SITE_DIR="$dir"
+ break 2
+ fi
+ done
+done
+if [ -z "$SITE_DIR" ]; then
+ echo "ERROR: No Hugo config found under docs/ or site/." >&2
+ exit 1
+fi
+```
+
+Use `$SITE_DIR` everywhere a directory path is needed in the steps below.
+
+### 1. Scope check
+
+Run `git diff ...HEAD --name-only` (default `` = `master` unless
+the user provides another). If the change set has **no** files under
+`$SITE_DIR/**` and no changes to `lychee.toml`, and the user did not
+explicitly ask, decline and exit cleanly.
+
+### 2. Preflight binaries
+
+- `hugo version` β must succeed; capture the version. If missing, stop with
+ Must-fix: "Install Hugo extended (`brew install hugo`)." If installed but
+ older than the workflow's `HUGO_VERSION` (parse with
+ `grep -E '^[[:space:]]+HUGO_VERSION:' .github/workflows/check-links.yml | sed -E 's/.*: *"?([^"]+)"?$/\1/'`), warn but
+ continue.
+- `node -v` and `npm -v` β must succeed. If missing, stop with Must-fix:
+ "Install Node (`brew install node`) at the major version pinned by
+ `node-version:` in `.github/workflows/check-links.yml`."
+- `lychee --version` β if it succeeds, record the path and version.
+- If `lychee` is missing:
+ 1. Read the canonical pin from the workflow file so the skill cannot drift
+ from CI:
+ ```bash
+ LYCHEE_VERSION_TAG=$(
+ grep -E '^[[:space:]]+LYCHEE_VERSION_TAG:' .github/workflows/check-links.yml \
+ | sed -E 's/.*: *"?([^"]+)"?$/\1/'
+ )
+ ```
+ Expected shape: `lychee-vX.Y.Z` (the leading `lychee-` is part of the
+ upstream release tag, not a typo).
+ 2. Determine platform via `uname -s` / `uname -m`. Map to the matching
+ Lychee asset (recent releases β `v0.24.2` and later β drop the
+ version from the asset filename):
+ - `Darwin` + `arm64` β `lychee-aarch64-apple-darwin.tar.gz`
+ - `Darwin` + `x86_64` β `lychee-x86_64-apple-darwin.tar.gz`
+ - `Linux` + `x86_64` β `lychee-x86_64-unknown-linux-gnu.tar.gz`
+ - `Linux` + `aarch64` β `lychee-aarch64-unknown-linux-gnu.tar.gz`
+ - any other combination (e.g. Windows, FreeBSD, 32-bit) β stop with
+ Must-fix: "Unsupported platform for Lychee auto-download β install
+ Lychee manually (`brew install lychee` / `cargo install lychee`)
+ and rerun."
+ 3. Ensure the cache directory exists *before* the download β
+ `mkdir -p .agents/skills/check-links/.cache/lychee/` β
+ because the path is git-ignored and absent on a fresh clone,
+ and `tar -xzf β¦ -C ` will fail with "no such file or
+ directory" if the target does not exist yet. This mirrors the
+ `mkdir -p lychee` that `check-links.yml` does before its own
+ extract step.
+ 4. Download from
+ `https://github.com/lycheeverse/lychee/releases/download/${LYCHEE_VERSION_TAG}/`
+ into `.agents/skills/check-links/.cache/lychee/` and extract
+ with `tar -xzf --strip-components=1 -C .agents/skills/check-links/.cache/lychee/`
+ so the binary lands at
+ `.agents/skills/check-links/.cache/lychee/lychee`.
+ 5. Use `.agents/skills/check-links/.cache/lychee/lychee` for the rest of this run.
+ 6. Print a one-line note: "Using auto-downloaded Lychee. For faster runs,
+ install with `brew install lychee`."
+
+### 3. Install Hugo deps
+
+Run `( cd ${SITE_DIR}/_preview && npm ci )`. We deliberately use `npm ci`
+(matching the CI workflow's `Install Dependencies` step in `check-links.yml`)
+rather than `npm install`:
+
+- `npm ci` installs exactly the versions pinned by `package-lock.json`;
+ `npm install` is allowed to update the lockfile and may resolve to
+ different transitive versions than CI, which defeats the "render
+ identical HTML to CI" goal.
+- If `package.json` and `package-lock.json` drift out of sync, `npm ci`
+ fails fast with a clear error rather than silently healing the
+ lockfile β a divergence we want to surface, not paper over.
+
+The helper script `${SITE_DIR}/_script/install-dependencies` exists for
+interactive use but does a relative `cd _preview` and therefore only works
+when invoked from `${SITE_DIR}/` β calling it from the repo root (the skill's
+default CWD) would fail with "No such file or directory: _preview".
+
+### 4. Build the site
+
+Run `( cd ${SITE_DIR}/_preview && hugo -e development )`.
+This emits `${SITE_DIR}/_preview/public/**/*.html`. The `-e development` flag
+matches what CI uses in `check-links.yml` so the two builds render identical
+HTML. (The helper `${SITE_DIR}/_script/hugo-build` exists for interactive use
+but defaults to `production`; we invoke `hugo` directly to keep the env in
+lock-step with CI.)
+
+### 5. Start the Hugo server in the background
+
+The server must survive across multiple `Bash` tool calls (steps 5 β 6 β 8
+typically run in separate shells), so we rely on `nohup` alone β a `trap β¦
+EXIT` would fire when *this* shell exits and kill the server before Lychee
+can query it. Teardown happens explicitly in step 8.
+
+Before launching, kill any leftover server from a previous crashed run so a
+stale process does not hold port `1414`:
+
+```bash
+pkill -F /tmp/check-links.hugo.pid 2>/dev/null || true
+rm -f /tmp/check-links.hugo.pid
+
+( cd ${SITE_DIR}/_preview && nohup hugo server --environment development --port 1414 \
+ > /tmp/check-links.hugo.out 2>&1 & echo $! > /tmp/check-links.hugo.pid )
+sleep 5
+
+# Verify the captured PID is alive before relying on it. `$!` for
+# `nohup foo &` is reliable on bash but not portable across shells; the
+# pgrep check turns a silent "Lychee fetches an empty port" failure into
+# a clear error.
+if ! pgrep -F /tmp/check-links.hugo.pid > /dev/null 2>&1; then
+ echo "ERROR: Hugo server failed to start. Tail of log:" >&2
+ tail -20 /tmp/check-links.hugo.out >&2 || true
+ exit 1
+fi
+```
+
+Port `1414` is chosen to avoid clashing with a developer's local `hugo server`
+(default `1313`). The `--environment development` flag matches CI's build env.
+
+### 6. Run Lychee
+
+```bash
+ --config lychee.toml --timeout 60 \
+ --base-url http://localhost:1414/ \
+ "${SITE_DIR}/_preview/public/**/*.html"
+```
+
+Capture exit code. Any non-zero exit means at least one broken link.
+
+### 7. Report
+
+Group the broken URLs from Lychee's output by source page. To reverse-map
+an HTML path to its Markdown source:
+
+`${SITE_DIR}/_preview/public/docs///index.html`
+β `${SITE_DIR}/content/docs//.md` (or `/_index.md`).
+
+Report in this shape:
+
+```
+## Doc link check ( vs )
+
+Hugo:
+Lychee: ()
+Pages scanned:
+Broken URLs:
+
+### /content/docs/<...>/.md
+- β
+- β ...
+
+### /content/docs/<...>/.md
+- ...
+```
+
+If `K == 0`, report a single line: "All links OK."
+
+### 8. Tear down and sentinel
+
+- Kill the Hugo server (and clean up its pid file):
+
+ ```bash
+ pkill -F /tmp/check-links.hugo.pid 2>/dev/null || true
+ rm -f /tmp/check-links.hugo.pid /tmp/check-links.hugo.out
+ ```
+
+ Run this even if Lychee failed β leaving a server on port `1414` would
+ poison the next invocation.
+- Write `.git/check-links.ok` at the repo root:
+
+ ```
+ head=
+ branch=
+ status=PASS|FAIL
+ timestamp=
+ hugo=
+ lychee=
+ pages=
+ broken=
+ ```
+
+The sentinel is consumed by the `pre-pr` skill's reviewer step: when it
+sees a sentinel whose `head=` matches the current HEAD SHA and
+`status=PASS`, it skips re-dispatching `check-links` and records it
+as APPROVE with the note "cached from `.git/check-links.ok`". Any
+HEAD advance (commit, amend, rebase) invalidates the cache automatically.
+
+## Notes
+
+- This skill does **not** modify tracked sources. It does, however, write
+ several git-ignored build artifacts during a run β listed here so a future
+ reader does not mistake them for unrelated side-effects:
+ - `.agents/skills/check-links/.cache/lychee/` β auto-downloaded
+ Lychee binary, when the system Lychee was unavailable.
+ - `${SITE_DIR}/_preview/node_modules/` β installed by `npm ci` in step 3.
+ - `${SITE_DIR}/_preview/public/` β Hugo's rendered HTML (the corpus Lychee
+ scans).
+ - `${SITE_DIR}/_preview/resources/` β Hugo's asset-pipeline cache.
+ - `.lycheecache` at the repo root β Lychee's per-URL result cache
+ (honoured for `max_cache_age = "3d"` per `lychee.toml`).
+ - `/tmp/check-links.hugo.{pid,out}` β server PID file and log, both
+ removed in step 8's teardown.
+
+ Every path above is matched by an existing `.gitignore` entry; none is
+ committed.
+- The `lychee.toml` exclude list is the single source of truth for flaky
+ external endpoints. If a real link must be excluded, add it there and
+ explain why in a comment so CI and local runs stay in sync.
+- The skill assumes the docs build succeeds. A Hugo build error is treated
+ the same as a link failure β surface it and stop.
+- The `include_verbatim = false` setting in `lychee.toml` skips links inside
+ code blocks. That is intentional today; flip it on if you specifically need
+ to validate examples.
+
+## Related skills
+
+- `review-docs` β prose, KDoc/Javadoc, and Markdown style review. Runs in
+ parallel with `check-links` when invoked by `pre-pr`.
+- `pre-pr` β composes the above and gates `gh pr create`.
diff --git a/.agents/skills/move-files/SKILL.md b/.agents/skills/move-files/SKILL.md
new file mode 100644
index 0000000..b92b05d
--- /dev/null
+++ b/.agents/skills/move-files/SKILL.md
@@ -0,0 +1,57 @@
+---
+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.
+ - Always use `git mv` for tracked files in the repo. If sandboxing blocks
+ it, request approval; do not use delete/create as a fallback.
+ - 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.
+
+6. Ensure the version is bumped.
+ Invoke `/version-bumped` so the branch carries a strictly greater
+ `version.gradle.kts` than the base ref before any `./gradlew build`
+ (which can transitively `publishToMavenLocal` and overwrite
+ consumer-facing snapshots). The skill is a no-op if a bump already
+ happened earlier on the branch.
+
+## 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 0000000..ba90a9f
--- /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/pre-pr/SKILL.md b/.agents/skills/pre-pr/SKILL.md
new file mode 100644
index 0000000..2c81dfd
--- /dev/null
+++ b/.agents/skills/pre-pr/SKILL.md
@@ -0,0 +1,181 @@
+---
+name: pre-pr
+description: >
+ Run the pre-PR checklist for this repo: apply the version gate only when
+ the repository has a root `version.gradle.kts`, run the configured
+ build/check command per `.agents/running-builds.md`, and invoke the
+ configured reviewers (`kotlin-review`, `review-docs`, `dependency-audit`,
+ `check-links`) against the branch diff. On success, write a sentinel file at
+ `.git/pre-pr.ok` so the `gh pr create` hook can verify the checklist ran
+ for the current HEAD. Use before opening a PR, or when CI rejected a
+ branch and you want a fast local repro.
+---
+
+# Pre-PR checklist (repo-specific)
+
+You are the pre-PR gate for this repository. You compose the existing
+reviewers and the documented repository rules into a single pass that must
+succeed before a pull request is opened.
+
+This skill supports both versioned Gradle Build Tools projects and repositories
+that intentionally do not have `version.gradle.kts`. Do not create
+`version.gradle.kts` just to satisfy this checklist. When the file is absent
+from the project root, the version-bump check is **not applicable**.
+
+The authoritative standards live in `.agents/`:
+
+- `.agents/version-policy.md` β applies only when the repository has a root
+ `version.gradle.kts`.
+- `.agents/running-builds.md` β which build/check command to run.
+- `.agents/safety-rules.md` and `.agents/advanced-safety-rules.md` β hard
+ constraints checked by the reviewers.
+
+## Procedure
+
+Run steps 1β4 fully before aggregating. Collect all findings; do not stop at
+the first failure.
+
+### 1. Determine scope and repository capabilities
+
+- Base ref: `master` unless the user provides a different one.
+- Changed files: `git diff ...HEAD --name-only`
+- Repository root: `git rev-parse --show-toplevel`
+- Version gate: check only the repository-root `version.gradle.kts`.
+ - Absent at both sides β `not-applicable`, continue.
+ - Present at `HEAD` β enforce in step 2.
+ - Present at `` but missing at `HEAD` β fail unless the user
+ explicitly asked to migrate away from Gradle Build Tools versioning.
+- Classify changes:
+ - **proto** β any `*.proto` changed
+ - **code** β any `*.kt`, `*.kts`, or `*.java` changed
+ - **docs** β any `*.md` or doc-only source edits changed
+ - **deps** β any file under `buildSrc/src/main/kotlin/io/spine/dependency/` changed
+ - **site** β any file under `docs/**` or `lychee.toml` (triggers Hugo link
+ check; pure `README.md` or KDoc-only changes do *not* count)
+
+### 2. Version-bump check
+
+- Skip when version gate is `not-applicable`.
+- Read `version.gradle.kts` at `HEAD`. Read `` only if the file exists
+ there; if it does not, the file is newly introduced β record the introduced
+ version and continue.
+- When both sides have the file: if the version is not strictly greater (semver
+ + Spine snapshot rules in `.agents/version-policy.md`): if
+ `.agents/skills/bump-version/` exists, **auto-fix immediately** by invoking
+ `/bump-version` without asking; otherwise record a Must-fix and continue.
+ Re-read the file after the fix. If the version is still not strictly greater,
+ record a Must-fix and continue. If the auto-fix succeeded, recompute the
+ changed-file list (`git diff ...HEAD --name-only`) before proceeding to
+ Step 3 β the bump commit adds `version.gradle.kts` to the diff.
+
+### 3. Build or check
+
+Pick the target per `.agents/running-builds.md`:
+
+- **proto** changed β `./gradlew clean build`
+- Else **code** changed β `./gradlew build`
+- Else **docs**-only β `./gradlew dokka`
+
+If `./gradlew` is absent, read `.agents/running-builds.md` for the
+repository-specific command. If that file is also absent, or if none is
+documented for the change type, record `build_status=skipped` with the
+reason and continue.
+
+Run the chosen command. On failure, record the first failing task and
+continue to step 4 β do not abort. Pass `build_status=FAIL` in the context
+given to reviewers so they can discount false positives from non-compiling
+code.
+
+### 4. Reviewers (run in parallel)
+
+Dispatch relevant reviewers concurrently; collect all verdicts before
+aggregating. Before dispatching, check that the skill directory exists under
+`.agents/skills/`; if a skill is absent, skip it with a note "not applicable
+for this repo" rather than failing.
+
+- **code** changed β `kotlin-review`
+- **docs** or KDoc changed β `review-docs`
+- **deps** changed β `dependency-audit`
+- **site** changed β `check-links` (unless the sentinel short-circuit below
+ applies)
+
+**`check-links` sentinel short-circuit.** Read `.git/check-links.ok` (if
+present). If `head=` equals the current **full** HEAD SHA and `status=PASS`, skip
+dispatch and record `APPROVE` with note "cached from `.git/check-links.ok`"
+(caching its ~30 s rebuild+serve cycle; the result is deterministic for a given
+HEAD). Otherwise dispatch normally.
+
+Pass each reviewer: base ref, changed-file list, build result, version result.
+When the version check is `not-applicable`, say so explicitly so reviewers don't flag a
+missing version bump.
+
+**Auto-fix policy for reviewer findings:**
+
+- Findings from `kotlin-review`, `review-docs`, or `dependency-audit` β record
+ as Must-fix or Should-fix; do **not** auto-apply. Surface them and wait for
+ user action.
+- If a reviewer reports a missing version bump after Step 2 already ran, the
+ auto-fix did not take β record a Must-fix and do not silently re-apply.
+- `dependency-audit` reports a **version rollback** β do **not** auto-fix.
+ Surface it as a Must-fix and wait for user confirmation, because a rollback
+ can be intentional.
+
+### 5. Aggregate
+
+- **PASS**: version check passed or `not-applicable`, build succeeded or
+ `build_status=skipped` (no documented command for the change type), every
+ reviewer returned `APPROVE` or `APPROVE WITH CHANGES`, and no unaddressed
+ Must-fix items remain.
+- **FAIL**: anything else.
+
+### 6. Sentinel
+
+Write `.git/pre-pr.ok` at the repo root (never under `.claude/`). The `gh pr
+create` hook (`.agents/scripts/pre-pr-gate.sh`) checks `head=` and `status=`;
+field names in this block are part of that contract.
+
+```
+head=
+branch=
+status=PASS|FAIL
+timestamp=
+build=
+build_status=PASS|FAIL|skipped
+reviewers=
+version=new, introduced:, or "not-applicable">
+```
+
+## Output format
+
+**On PASS** β single line:
+
+```
+Pre-PR: PASS ( vs ) β ready to `gh pr create`.
+```
+
+**On FAIL** β header line, then only the items that need attention, each
+prefixed with the source reviewer or check:
+
+```
+Pre-PR: FAIL ( vs )
+
+Must fix:
+- [kotlin-review]
+- [review-docs]
+
+Should fix:
+- [dependency-audit]
+```
+
+Report nothing about checks that passed. If auto-fixes were applied, list
+them in one line before the verdict: `Auto-fixed: .`
+
+## Notes
+
+- This skill must NOT create the PR itself.
+- This skill must NOT create `version.gradle.kts`.
+- The sentinel lives under `.git/` β per-clone, never committed.
+- Each reviewer is the source of truth for its own checks; this skill only
+ orchestrates and aggregates.
+- This skill may auto-fix a missing version bump by invoking `/bump-version`;
+ all other fixes require explicit user confirmation.
diff --git a/.agents/skills/review-docs/SKILL.md b/.agents/skills/review-docs/SKILL.md
new file mode 100644
index 0000000..d936fa2
--- /dev/null
+++ b/.agents/skills/review-docs/SKILL.md
@@ -0,0 +1,129 @@
+---
+name: review-docs
+description: >
+ Review documentation changes β KDoc/Javadoc inside Kotlin/Java sources and
+ Markdown docs (`README.md`, `docs/**`) β against Spine documentation
+ conventions. Use when a diff touches doc comments or Markdown, before
+ opening a doc-affecting PR, or when asked for a documentation review.
+ Read-only; does not run builds.
+---
+
+# Review documentation (repo-specific)
+
+You are the documentation reviewer for a Spine Event Engine project. You
+focus strictly on documentation quality β prose, KDoc/Javadoc, and Markdown β
+and deliberately do **not** duplicate the code-review skill (which owns
+Kotlin idioms, safety rules, tests, and version-gate checks).
+
+The authoritative standards live in `.agents/`:
+
+- `.agents/documentation-guidelines.md` β commenting rules, TODO-comment
+ format, "file/dir names as code", widow/runt/orphan/river rule (with the
+ diagram at `.agents/widow-runt-orphan.jpg`).
+- `.agents/documentation-tasks.md` β KDoc-example requirement on APIs;
+ Javadoc β KDoc conversion rules (`
` removal, etc.).
+- `.agents/skills/writer/SKILL.md` β Markdown conventions (footnote-style
+ reference links for external URLs, typographic quotes only on actual
+ page/section titles, sidenav-sync rules under `docs/`).
+- `.agents/running-builds.md` β for doc-only Kotlin/Java changes the right
+ build is `./gradlew dokka` (no tests required).
+
+## Review procedure
+
+1. **Scope the diff.** Obtain the change set via `git diff --staged` or
+ `git diff ...HEAD` depending on what the user describes. Restrict
+ to files matching:
+ - `**/*.kt`, `**/*.kts`, `**/*.java` (for KDoc/Javadoc inside sources)
+ - `**/*.md` (Markdown docs)
+ Do **not** review the full repo β only what changed.
+
+2. **Read each affected file fully, not just the hunks.** Prose review
+ requires surrounding context β judging widows/runts/orphans, link
+ placement, and KDoc completeness needs the whole paragraph and the
+ surrounding declarations.
+
+3. **Stay in scope.** If you spot a code-quality issue (idiom, naming,
+ tests, version-gate applicability), note it briefly as a "for the code
+ reviewer" item under Nits β do not expand the review.
+
+## Checks
+
+### A. KDoc / Javadoc inside sources
+
+- **Public and internal APIs carry KDoc.** Per `documentation-tasks.md`,
+ KDoc should include at least one usage example for non-trivial APIs.
+ Missing KDoc on a new or modified public/internal symbol is a Should-fix.
+- **No Javadoc residue in Kotlin.** When converting from Java:
+ - `
` tags on a text line removed (`"
This"` β `"This"`).
+ - `
` on its own line replaced with a blank line.
+ - HTML entities (`&`, `<`, β¦) converted to literals where appropriate.
+- **Inline comments in production code are minimized.** Inline comments are
+ fine in tests; in production source they should explain *why* (a
+ constraint, invariant, surprise) and never restate *what* the code does.
+- **TODO comments follow the Spine format.** Linked from
+ `documentation-guidelines.md` to the wiki "TODO-comments" page. A bare
+ `// TODO: β¦` without owner/issue reference is a Should-fix.
+- **File and directory names rendered as code.** Within KDoc/Javadoc prose,
+ `path/to/file.kt` and `module-name` must use backticks.
+
+### B. Markdown docs
+
+- **Footnote-style reference links** for external `https://` URLs (per the
+ `writer` skill). Inline `[label](https://β¦)` in body prose is a
+ Should-fix; inline links to local relative paths are fine.
+- **Typographic quotes** (`" "` / `' '`) only when the visible link text is
+ an actual page or section title (e.g., the "Getting started" page).
+ Do **not** quote generic phrases like "this page", "the next section",
+ "What's next", or section numbers (`4.3`).
+- **Sidenav sync.** If the diff adds/removes/renames/moves a page under
+ `docs/content/docs//`, the matching current-version
+ `sidenav.yml` must be updated (see the `writer` skill for how to
+ identify the current version via `docs/data/versions.yml`). A missing
+ sidenav update is a Must-fix.
+- **Fenced code blocks** for commands and examples β no indented code
+ blocks for shell snippets (they swallow `$` prompts and hurt copy/paste).
+- **Heading hierarchy.** No skipped levels (`#` β `###`); exactly one `#`
+ per file.
+
+### C. Prose flow (Spine-specific)
+
+- **Avoid widows, runts, orphans, and rivers** β the rule from
+ `documentation-guidelines.md` with the diagram at
+ `.agents/widow-runt-orphan.jpg`. Operationally:
+ - **Widow / runt**: a paragraph's last line containing only one short
+ word (or a hyphenated fragment). Reflow the prior line.
+ - **Orphan**: a single trailing line of a paragraph stranded at the top
+ of a new block (often appears after a heading or list). Reflow.
+ - **River**: a vertical "gap" of aligned spaces running down justified
+ text. Rare in Markdown but possible in tables β reflow the table or
+ rewrite to break the alignment.
+ Quote the offending paragraph and propose a rewording that fixes it.
+
+### D. Terminology and tone
+
+- **Match code identifiers verbatim.** When prose references a class,
+ function, or property, the name in backticks must match the source
+ exactly (case, plurality).
+- **Consistent terminology across the diff.** If the same concept is
+ named two different ways in the same change set, pick one.
+
+## Output format
+
+Three sections, in this order:
+
+- **Must fix** β broken/missing KDoc on a newly-introduced public API,
+ missing sidenav sync, broken cross-references, Javadoc residue
+ (`
` tags) left in Kotlin KDoc, broken Markdown links.
+- **Should fix** β TODO format, inline-comment overuse in production,
+ inline external links that should be footnote-style, missing typographic
+ quotes (or unwanted ones), widow/runt/orphan/river paragraphs,
+ fenced-vs-indented code blocks.
+- **Nits** β wording, terminology drift, code-identifier capitalization
+ in prose, "for the code reviewer" pointers if any code issues surfaced
+ incidentally.
+
+For each finding, cite the file and line, quote the offending text, and
+show the recommended rewrite. If a section is empty, write "None."
+
+End with a one-line verdict: `APPROVE`, `APPROVE WITH CHANGES`, or
+`REQUEST CHANGES`.
diff --git a/.agents/skills/update-copyright/SKILL.md b/.agents/skills/update-copyright/SKILL.md
new file mode 100644
index 0000000..6afc4c7
--- /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 0000000..246dd64
--- /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 0000000..2dbf8bb
--- /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 0000000..8770b32
--- /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/.agents/skills/version-bumped/SKILL.md b/.agents/skills/version-bumped/SKILL.md
new file mode 100644
index 0000000..86ca53d
--- /dev/null
+++ b/.agents/skills/version-bumped/SKILL.md
@@ -0,0 +1,99 @@
+---
+name: version-bumped
+description: >
+ Verify the current branch has bumped `version.gradle.kts` strictly above
+ the base ref; invoke `/bump-version` to auto-recover if not. Composable:
+ other modifying skills (`dependency-update`, `bump-gradle`,
+ `java-to-kotlin`, `move-files`) call this as their final step so a
+ `./gradlew build` or `publishToMavenLocal` can never overwrite a
+ previously published Maven Local artifact that integration tests in
+ consumer repos depend on.
+---
+
+# Ensure version is bumped
+
+This skill is the agent-facing wrapper around
+`.agents/skills/version-bumped/scripts/version-bumped.sh`. The script is the source of truth for
+"has this branch advanced the version vs base?"; this skill just runs it
+and, if it fails, invokes `/bump-version` and re-runs to confirm.
+
+The same logic is enforced as a hook
+(`.agents/scripts/publish-version-gate.sh`) that fires before any
+`./gradlew β¦ (build|publish|publishToMavenLocal)` invocation, so even
+direct gradle calls cannot bypass it. This skill exists for the
+cooperative path β other skills calling it before they finish, so the
+user is never surprised by a blocked gradle command later.
+
+The premise is simple: any feature branch is a candidate for publishing,
+even when the only change is the version bump itself (sometimes the bump
+is the entire change, used to retry a publish that failed because Maven
+repositories were overloaded). So if the branch differs from base at all,
+the version must advance.
+
+## When to use
+
+- Automatically: as the final step of any skill that may change files on
+ the branch.
+- Manually (`/version-bumped`): before running `./gradlew build` or
+ `./gradlew publishToMavenLocal` on a feature branch when you are not
+ sure whether the version has already been bumped.
+
+## Procedure
+
+1. Run the deterministic check:
+
+ ```bash
+ .agents/skills/version-bumped/scripts/version-bumped.sh
+ ```
+
+ Honor `VERSION_BUMPED_BASE` if the user has set a non-default base ref
+ (e.g. `origin/master`, or a release branch).
+
+2. Interpret the exit code:
+
+ - **0** β Done. Either the repository has no root `version.gradle.kts`
+ (the version check is `N/A`), the branch has no diff vs base, or the
+ version is already strictly greater. Report a one-line confirmation
+ and stop.
+ - **1** β Block. The script's stderr explains which check failed.
+ Proceed to step 3.
+ - **2** β Configuration error (no merge-base, parse failure on
+ `version.gradle.kts`). Do **not** invoke `/bump-version`
+ automatically. Surface the script's stderr to the user and stop.
+
+3. On exit 1, invoke `/bump-version` to perform the actual bump. That
+ skill owns the policy (snapshot numbering, the commit subject, the
+ rebuild, dependency-report regeneration, and the conflict rule). Do
+ not duplicate its work here.
+
+4. After `/bump-version` finishes, re-run the deterministic check. If it
+ now passes, report the new version on the branch. If it still fails,
+ surface the stderr unchanged and stop β do not loop.
+
+## Why this skill is separate from `/bump-version`
+
+`/bump-version` is the **action** (it edits `version.gradle.kts`,
+commits, rebuilds, may commit reports). `/version-bumped` is the
+**guard** (read-only check, optional auto-recovery). Skills that want to
+say "make sure the branch has a bumped version" should call
+`/version-bumped`, not `/bump-version`, because the guard is a no-op when
+the bump is already done β calling `/bump-version` unconditionally would
+double-bump on every chained skill invocation.
+
+## Relationship to `checkVersionIncrement`
+
+The Gradle task `checkVersionIncrement` (in `buildSrc/.../publish/`)
+asks a different question: *"is this version already in the remote
+Maven metadata?"* It runs on GitHub Actions feature-branch pushes and
+fetches the Spine SDK Artifact Registry. The two checks are
+complementary β neither subsumes the other.
+
+## See also
+
+- `.agents/version-policy.md` β when the version gate applies.
+- `.agents/skills/bump-version/SKILL.md` β the bump procedure itself.
+- `.agents/skills/pre-pr/SKILL.md` β uses the same check at PR time
+ (step 2).
+- `.agents/skills/version-bumped/scripts/version-bumped.sh` β the deterministic check.
+- `.agents/scripts/publish-version-gate.sh` β the hook that enforces the
+ rule on `./gradlew` invocations.
diff --git a/.agents/skills/version-bumped/scripts/version-bumped.sh b/.agents/skills/version-bumped/scripts/version-bumped.sh
new file mode 100755
index 0000000..f050a5b
--- /dev/null
+++ b/.agents/skills/version-bumped/scripts/version-bumped.sh
@@ -0,0 +1,276 @@
+#!/usr/bin/env bash
+#
+# Verifies that a feature branch which differs from the base ref also
+# bumps `version.gradle.kts` strictly above the base version. Mirrors the
+# universal "every branch advances the version" policy: a branch with any
+# changes is a candidate for publishing β sometimes the only change is the
+# bump itself, used to retry a publish that failed because Maven
+# repositories were overloaded.
+#
+# Exit codes:
+# 0 β OK: repo has no root `version.gradle.kts`, OR branch has no diff
+# vs base, OR working-tree version is strictly greater than base
+# version.
+# 1 β Block: branch differs from base but version is unchanged or
+# decreased. Stderr points to `/bump-version`.
+# 2 β Configuration error (bad base ref, parse failure). Stderr explains.
+#
+# Inputs (env, all optional):
+# VERSION_BUMPED_BASE Base ref to compare against. Default: master,
+# then main if master is absent.
+# VERSION_BUMPED_KEY Name of the `extra` property holding the
+# publishing version (e.g. `versionToPublish`,
+# `validationVersion`, `bootstrapVersion`). When
+# set, bypasses auto-discovery. Useful for repos
+# that don't follow the `version = extra["β¦"]`
+# pattern in `build.gradle.kts`.
+# VERSION_BUMPED_QUIET When `1`, suppress the "OK" line on stdout.
+# The publish-version-gate hook sets this.
+#
+# Publishing-key discovery:
+# The publishing version's variable name varies across Spine repos
+# (`versionToPublish`, `validationVersion`, `compilerVersion`, β¦).
+# `version.gradle.kts` may also declare other `val xxxVersion by extra(...)` entries
+# that are *dependency* versions of other Spine modules β not this
+# project's own publishing version β so the key cannot be picked by
+# inspecting `version.gradle.kts` alone.
+#
+# The canonical source is `build.gradle.kts`, which assigns
+# `version = extra["KEY"]!!`. This script scans for that pattern,
+# picks the unique key, and parses its value from `version.gradle.kts`.
+# If `build.gradle.kts` does not contain such a line, the script falls
+# back to `versionToPublish`. Set `VERSION_BUMPED_KEY` to override.
+#
+# Notes:
+# * Companion to the Gradle task `checkVersionIncrement` (see
+# `buildSrc/.../publish/CheckVersionIncrement.kt`). The Gradle task
+# asks "is this version already in remote Maven metadata?" β this
+# script asks the simpler local question "has this branch advanced
+# the version vs base?". The two checks are complementary; neither
+# subsumes the other.
+# * The working tree is included in the change-detection so the gate
+# reflects what `./gradlew build` would actually publish.
+#
+set -eu
+
+repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
+ echo "version-bumped: not inside a git repository" >&2
+ exit 2
+}
+cd "$repo_root"
+
+version_file="version.gradle.kts"
+
+# --- N/A: not a versioned project ----------------------------------------
+if [ ! -f "$version_file" ]; then
+ [ "${VERSION_BUMPED_QUIET:-0}" = "1" ] || echo "version-bumped: N/A (no root version.gradle.kts)"
+ exit 0
+fi
+
+# --- Resolve base ref ----------------------------------------------------
+base="${VERSION_BUMPED_BASE:-}"
+if [ -z "$base" ]; then
+ if git show-ref --verify --quiet refs/heads/master; then
+ base=master
+ elif git show-ref --verify --quiet refs/heads/main; then
+ base=main
+ else
+ echo "version-bumped: no master or main branch found; set VERSION_BUMPED_BASE" >&2
+ exit 2
+ fi
+fi
+
+if ! git rev-parse --verify --quiet "$base" >/dev/null; then
+ echo "version-bumped: base ref '$base' does not resolve" >&2
+ exit 2
+fi
+
+# When we are on the base branch itself, there is nothing to gate.
+current_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
+if [ "$current_branch" = "$base" ] || [ "$current_branch" = "${base##*/}" ]; then
+ [ "${VERSION_BUMPED_QUIET:-0}" = "1" ] || echo "version-bumped: on base branch ($current_branch); nothing to gate"
+ exit 0
+fi
+
+merge_base=$(git merge-base HEAD "$base" 2>/dev/null) || {
+ echo "version-bumped: cannot find merge-base of HEAD and '$base'" >&2
+ exit 2
+}
+
+# --- Detect any branch divergence vs base (committed/worktree/untracked) -
+committed=$(git diff --name-only "$merge_base"..HEAD 2>/dev/null || true)
+worktree=$(git diff --name-only HEAD 2>/dev/null || true)
+untracked=$(git ls-files --others --exclude-standard 2>/dev/null || true)
+
+if [ -z "$committed" ] && [ -z "$worktree" ] && [ -z "$untracked" ]; then
+ [ "${VERSION_BUMPED_QUIET:-0}" = "1" ] || echo "version-bumped: no changes vs $base"
+ exit 0
+fi
+
+# --- Discover the publishing-version key ---------------------------------
+# Source of truth is `build.gradle.kts` (or `build.gradle`). Two shapes are
+# recognised, in order:
+#
+# a) version = extra["KEY"]
+# b) version = IDENTIFIER (with `val IDENTIFIER ... by extra` nearby)
+#
+# Single or double quotes are accepted in shape (a). If multiple distinct
+# keys appear across shapes, the script refuses to guess and asks the user
+# to set VERSION_BUMPED_KEY.
+#
+# Return codes:
+# 0 β printed a unique key on stdout
+# 1 β no candidates found (caller should fall back)
+# 2 β ambiguous; diagnostic already on stderr
+discover_key() {
+ local files keys_a keys_b keys count
+ files=""
+ [ -f build.gradle.kts ] && files="build.gradle.kts"
+ [ -f build.gradle ] && files="$files build.gradle"
+ [ -z "$files" ] && return 1
+ # Shape (a): version = extra["KEY"]
+ # Anchored to start-of-line (modulo leading whitespace) so that comments
+ # like `// version = extra["x"]` and identifiers like `fooversion = ...`
+ # don't produce false matches.
+ # shellcheck disable=SC2086
+ keys_a=$(grep -hE '^[[:space:]]*version[[:space:]]*=[[:space:]]*extra[[:space:]]*\[[[:space:]]*["'"'"'][^"'"'"']+["'"'"']' $files 2>/dev/null \
+ | sed -nE 's/.*extra[[:space:]]*\[[[:space:]]*["'"'"']([^"'"'"']+)["'"'"'].*/\1/p')
+ # Shape (b): version = IDENTIFIER (bare Kotlin identifier, no '[' or '"').
+ # Only accept the identifier if the same file also declares
+ # `val IDENTIFIER[: String]? by extra` β otherwise it's a plain local
+ # variable (common in Groovy `build.gradle`), not an `extra` property we
+ # can resolve in `version.gradle.kts`.
+ local candidates_b cand
+ # shellcheck disable=SC2086
+ candidates_b=$(grep -hE '^[[:space:]]*version[[:space:]]*=[[:space:]]*[A-Za-z_][A-Za-z0-9_]*[[:space:]]*$' $files 2>/dev/null \
+ | sed -nE 's/^[[:space:]]*version[[:space:]]*=[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*$/\1/p')
+ keys_b=""
+ for cand in $candidates_b; do
+ # shellcheck disable=SC2086
+ if grep -hE "^[[:space:]]*val[[:space:]]+${cand}([[:space:]]*:[[:space:]]*String)?[[:space:]]+by[[:space:]]+extra([^A-Za-z0-9_]|\$)" $files >/dev/null 2>&1; then
+ keys_b="${keys_b}${cand}
+"
+ fi
+ done
+ keys=$(printf '%s\n%s' "$keys_a" "$keys_b" | sed '/^$/d' | sort -u)
+ [ -z "$keys" ] && return 1
+ count=$(printf '%s\n' "$keys" | wc -l | tr -d ' ')
+ if [ "$count" -gt 1 ]; then
+ {
+ echo "version-bumped: ambiguous publishing key in build scripts:"
+ while IFS= read -r k; do printf ' %s\n' "$k"; done <<< "$keys"
+ echo " Set VERSION_BUMPED_KEY to disambiguate."
+ } >&2
+ return 2
+ fi
+ printf '%s' "$keys"
+}
+
+key="${VERSION_BUMPED_KEY:-}"
+if [ -z "$key" ]; then
+ set +e
+ key=$(discover_key)
+ rc=$?
+ set -e
+ if [ "$rc" = "2" ]; then
+ exit 2
+ fi
+ if [ "$rc" != "0" ] || [ -z "$key" ]; then
+ key="versionToPublish"
+ fi
+fi
+
+# --- Parse a `val KEY by extra(...)` from a Gradle file content ----------
+# Handles three shapes (per .agents/skills/bump-version/SKILL.md step 2):
+# 1. val KEY[: String]? by extra("X") β literal extra
+# 2. val SRC[: String]? by extra("X") β alias chain via extra
+# val KEY[: String]? by extra(SRC)
+# 3. val SRC[: String]? = "X" β alias chain via plain val
+# val KEY[: String]? by extra(SRC)
+# The key name is parameterized so that any project-specific name works
+# (versionToPublish, validationVersion, bootstrapVersion, botVersion, β¦).
+parse_version() {
+ local content="$1" name="$2"
+ local v varName
+ # Shape 1: literal.
+ v=$(printf '%s' "$content" \
+ | grep -E "val[[:space:]]+${name}([[:space:]]*:[[:space:]]*String)?[[:space:]]+by[[:space:]]+extra\(\"" \
+ | head -n1 \
+ | sed -nE 's/.*extra\("([^"]+)".*/\1/p')
+ if [ -n "$v" ]; then
+ printf '%s' "$v"
+ return 0
+ fi
+ # Shapes 2 & 3: extract the alias source identifier.
+ varName=$(printf '%s' "$content" \
+ | grep -E "val[[:space:]]+${name}([[:space:]]*:[[:space:]]*String)?[[:space:]]+by[[:space:]]+extra\(" \
+ | head -n1 \
+ | sed -nE 's/.*extra\(([A-Za-z_][A-Za-z0-9_]*)\).*/\1/p')
+ if [ -n "$varName" ]; then
+ # Shape 2: source is `val SRC ... by extra("X")`.
+ v=$(printf '%s' "$content" \
+ | grep -E "val[[:space:]]+${varName}([[:space:]]*:[[:space:]]*String)?[[:space:]]+by[[:space:]]+extra\(\"" \
+ | head -n1 \
+ | sed -nE 's/.*extra\("([^"]+)".*/\1/p')
+ if [ -n "$v" ]; then
+ printf '%s' "$v"
+ return 0
+ fi
+ # Shape 3: source is `val SRC[: String]? = "X"`.
+ v=$(printf '%s' "$content" \
+ | grep -E "val[[:space:]]+${varName}([[:space:]]*:[[:space:]]*String)?[[:space:]]*=[[:space:]]*\"" \
+ | head -n1 \
+ | sed -nE 's/.*=[[:space:]]*"([^"]+)".*/\1/p')
+ if [ -n "$v" ]; then
+ printf '%s' "$v"
+ return 0
+ fi
+ fi
+ return 1
+}
+
+head_content=$(cat "$version_file" 2>/dev/null || true)
+head_version=$(parse_version "$head_content" "$key" || true)
+if [ -z "$head_version" ]; then
+ echo "version-bumped: cannot parse '$key' from working-tree $version_file" >&2
+ exit 2
+fi
+
+# Base content may legitimately not exist (file newly introduced).
+base_content=$(git show "$base:$version_file" 2>/dev/null || true)
+if [ -z "$base_content" ]; then
+ [ "${VERSION_BUMPED_QUIET:-0}" = "1" ] || echo "version-bumped: $version_file newly introduced at $head_version; treating as bumped"
+ exit 0
+fi
+
+base_version=$(parse_version "$base_content" "$key" || true)
+if [ -z "$base_version" ]; then
+ echo "version-bumped: cannot parse '$key' from $base:$version_file" >&2
+ exit 2
+fi
+
+# --- Strict-greater comparison via `sort -V` -----------------------------
+if [ "$head_version" = "$base_version" ]; then
+ cmp="equal"
+elif [ "$(printf '%s\n%s\n' "$base_version" "$head_version" | sort -V | tail -n1)" = "$head_version" ]; then
+ cmp="greater"
+else
+ cmp="lesser"
+fi
+
+if [ "$cmp" = "greater" ]; then
+ [ "${VERSION_BUMPED_QUIET:-0}" = "1" ] || echo "version-bumped: OK ($key: $base_version -> $head_version)"
+ exit 0
+fi
+
+cat >&2 <
+ Write, edit, and restructure user-facing and developer-facing documentation.
+ Use when asked to create/update docs such as `README.md`, `docs/**`, and
+ other Markdown documentation, including keeping docs navigation data in sync;
+ when drafting tutorials, guides, troubleshooting pages, or migration notes; and
+ when improving inline API documentation (KDoc) and examples.
+---
+
+# Write documentation (repo-specific)
+
+## Decide the target and audience
+
+- Identify the target reader: end user, contributor, maintainer, or tooling/automation.
+- Identify the task type: new doc, update, restructure, or documentation audit.
+- Identify the acceptance criteria: βwhat is correct when the reader is done?β
+
+## Choose where the content should live
+
+- Prefer updating an existing doc over creating a new one.
+- Place content in the most discoverable location:
+ - `README.md`: project entry point and βwhat is this?β.
+ - `docs/`: longer-form docs (follow existing conventions in that tree).
+ - Source KDoc: API usage, examples, and semantics that belong with the code.
+
+## Keep docs navigation in sync
+
+- When adding, removing, moving, or renaming a page under
+ `docs/content/docs//`, keep the current version's matching
+ `sidenav.yml` in sync.
+- Use `docs/data/versions.yml` to identify the current documentation version for
+ that section. The current version is the entry with `is_main: true`; its
+ `version_id` maps to `docs/data/docs///sidenav.yml`.
+- Do not update historical version entries or their navigation files unless the
+ user explicitly asks to edit that historical version.
+- Map page files to `file_path` values relative to the current version's
+ `content_path`, without `.md`; `_index.md` maps to its directory path, such as
+ `01-getting-started/_index.md` -> `01-getting-started`.
+- Keep each `page` label aligned with the page frontmatter `title` unless the
+ existing navigation intentionally uses a shorter reader-facing label.
+- Preserve the existing ordering, nesting, keys, comments, and YAML quoting
+ style. Remove nav entries for deleted pages and update `file_path` values for
+ moved pages.
+- If a docs content change should not appear in navigation, say so explicitly in
+ the final response.
+
+## Follow local documentation conventions
+
+- Follow `.agents/documentation-guidelines.md` and `.agents/documentation-tasks.md`.
+- Use fenced code blocks for commands and examples; format file/dir names as code.
+- When referencing a documentation page or section in body prose, use typographic
+ double quotation marks only if the visible reference text is the actual page or
+ section title, such as the βGetting startedβ page or the βTroubleshootingβ
+ section. The title normally starts with a capital letter. Do not add these
+ quotes around generic or descriptive links such as βthis pageβ, βthe next
+ sectionβ, βdeclaring constraintsβ, or `4.3`, even if they point to a page or
+ section. Do not add these quotes in βWhatβs nextβ sections or navigation
+ elements. Keep file paths, identifiers, frontmatter values, navigation labels,
+ and Markdown link labels in their expected syntax.
+- In Markdown files, prefer footnote-style reference links for external `https://`
+ targets instead of inline links. Write readable body text like
+ `[label][short-id]`, then place the URL definition near the end of the file,
+ such as `[short-id]: https://example.com/long/path`. Keep reference IDs short
+ and descriptive. Inline links are still fine for local relative paths.
+- Avoid widows, runts, orphans, and rivers by reflowing paragraphs when needed.
+
+## Make docs actionable
+
+- Prefer steps the reader can execute (commands + expected outcome).
+- Prefer concrete examples over abstract descriptions.
+- Include prerequisites (versions, OS, environment) when they are easy to miss.
+- Use consistent terminology (match code identifiers and existing docs).
+
+## KDoc-specific guidance
+
+- For public/internal APIs, include at least one example snippet demonstrating common usage.
+- When converting from Javadoc/inline comments to KDoc:
+ - Remove HTML like `
` and preserve meaning.
+ - Prefer short paragraphs and blank lines over HTML formatting.
+
+## Validate changes
+
+- For code changes, follow `.agents/running-builds.md`.
+- For documentation-only changes in Kotlin/Java sources, prefer `./gradlew dokka`.
diff --git a/.agents/skills/writer/agents/openai.yaml b/.agents/skills/writer/agents/openai.yaml
new file mode 100644
index 0000000..44eaa4e
--- /dev/null
+++ b/.agents/skills/writer/agents/openai.yaml
@@ -0,0 +1,5 @@
+interface:
+ display_name: "Writer"
+ short_description: "Write and update user/developer docs"
+ default_prompt: "Write or revise documentation in this repository (for example: README.md, docs/**, CONTRIBUTING.md, and API documentation/KDoc). Follow local documentation guidelines in .agents/*.md, keep changes concise and actionable, and include concrete examples and commands where appropriate."
+
diff --git a/.agents/skills/writer/assets/templates/doc-page.md b/.agents/skills/writer/assets/templates/doc-page.md
new file mode 100644
index 0000000..f405b71
--- /dev/null
+++ b/.agents/skills/writer/assets/templates/doc-page.md
@@ -0,0 +1,23 @@
+# Title
+
+## Goal
+
+State what the reader will accomplish.
+
+## Prerequisites
+
+- List versions/tools the reader needs.
+
+## Steps
+
+1. Do the first thing.
+2. Do the next thing.
+
+## Verify
+
+Show how the reader can confirm success.
+
+## Troubleshooting
+
+- Common failure: likely cause β fix.
+
diff --git a/.agents/skills/writer/assets/templates/kdoc-example.md b/.agents/skills/writer/assets/templates/kdoc-example.md
new file mode 100644
index 0000000..fdbd9b6
--- /dev/null
+++ b/.agents/skills/writer/assets/templates/kdoc-example.md
@@ -0,0 +1,11 @@
+````kotlin
+/**
+ * Explain what this API does in one sentence.
+ *
+ * ## Example
+ * ```kotlin
+ * // Show the typical usage pattern.
+ * val result = doThing()
+ * ```
+ */
+````
diff --git a/.agents/skills/writer/assets/templates/kotlin-java-example.md b/.agents/skills/writer/assets/templates/kotlin-java-example.md
new file mode 100644
index 0000000..5517516
--- /dev/null
+++ b/.agents/skills/writer/assets/templates/kotlin-java-example.md
@@ -0,0 +1,13 @@
+{{< code-tabs langs="Kotlin, Java">}}
+
+{{< code-tab lang="Kotlin" >}}
+```kotlin
+```
+{{< /code-tab >}}
+
+{{< code-tab lang="Java" >}}
+```java
+```
+{{< /code-tab >}}
+
+{{< /code-tabs >}}
diff --git a/.agents/tasks/README.md b/.agents/tasks/README.md
new file mode 100644
index 0000000..325f52c
--- /dev/null
+++ b/.agents/tasks/README.md
@@ -0,0 +1,128 @@
+# Task plans β `.agents/tasks/`
+
+Durable task plans. Checked into git so the whole team β and any
+agent working in this repo β can review, resume, or pick up
+sub-tasks across sessions.
+
+This complements Claude Code's built-in Plan mode and in-session
+task list: the file here is the durable source of truth; the
+built-in tools gate approval and render live progress.
+
+## Layout
+
+ .agents/tasks/
+ βββ README.md # This file
+ βββ .md # One file per task; status in frontmatter
+
+Filename = the task's kebab-case slug. Multiple active tasks per
+branch are allowed β use distinct slugs.
+
+## File format
+
+ ---
+ slug: add-team-memory
+ branch: tune-claude
+ owner: claude # or a human/agent handle
+ status: in-progress # see status values below
+ started: 2026-05-19
+ related-memories: # optional β links into .agents/memory/
+ - team-memory-routing
+ ---
+
+ ## Goal
+
+
+ ## Context
+
+
+ ## Plan
+ - [x] Step 1
+ - [ ] Step 2
+ - notes / sub-bullets
+ - [ ] Step 3
+
+ ## Log
+ - 2026-05-19 14:02 β drafted, awaiting approval
+ - 2026-05-19 14:15 β approved, executing
+ - 2026-05-19 14:42 β step 3 blocked on β¦
+
+The checklist uses `- [ ]` / `- [x]` so another agent can claim and
+complete unchecked items by ticking them and adding a `Log` line.
+
+### `status` values
+
+| value | meaning |
+|---|---|
+| `draft` | written but not yet approved |
+| `approved` | approved, not yet started |
+| `in-progress` | execution under way |
+| `blocked` | paused; reason in `Log` |
+| `in-review` | work done, awaiting review |
+| `done` | complete β file is then deleted (see lifecycle) |
+
+## Workflow
+
+1. **Discover** β at task start, scan `.agents/tasks/` for
+ in-progress or blocked plans on the current branch. Resume
+ rather than restart.
+2. **Draft** β write `.md` with `status: draft` and the
+ plan checklist.
+3. **Approval gate** β `EnterPlanMode` β `ExitPlanMode`. The plan
+ presented to the human references the file path; the human may
+ edit the file directly before approving.
+4. **Mirror** β on approval, flip `status: approved` β `in-progress`
+ and populate `TaskCreate` from the top-level checklist for live
+ in-session progress.
+5. **Execute + sync** β use `TaskUpdate` for fine-grained progress.
+ Edit the file only at meaningful checkpoints: step done, blocker,
+ scope change, new note.
+6. **Complete** β flip `status: done`. The file is raw material for
+ the PR description.
+7. **Delete on merge** β once the branch lands on master, delete the
+ task file in the same commit or shortly after. `git log --follow`
+ recovers it if ever needed.
+
+## Cross-agent coordination
+
+- Other agents (or other CC sessions) `Read` the file to pick up
+ state. They MUST update `status`, tick checkboxes, and append
+ `Log` lines rather than rewriting the plan silently.
+- If two agents work the same task in parallel, partition by
+ checkbox β each agent claims unchecked items by tagging the line
+ (e.g. `- [ ] (owner: reviewer-bot) Run dependency-audit`) or by
+ appending a `Log` line.
+- The **file** is the contract. In-session `TaskCreate` state is
+ per-session and not authoritative.
+
+## When to create a task file
+
+Create one whenever the work is non-trivial:
+
+- Changes spanning multiple files or modules (features, refactors).
+- Lengthy documentation work β multi-page guides, restructuring
+ `docs/`, migration notes, tutorials. The task file plans and
+ tracks the effort; the docs-related skills (`writer`,
+ `write-docs`, `review-docs`) handle individual page work inside
+ the plan steps.
+- Cross-agent or cross-session work (e.g. one agent drafts, another
+ reviews).
+- Anything that may span sessions and needs durable state.
+
+Do **not** create a task file for:
+
+- Trivial changes (single-file edits, typo fixes, version bumps) β
+ pure overhead.
+- Deliverables themselves β code lives in source, docs in `docs/`,
+ design records where the project keeps them. Task files describe
+ the *work*, not the artifact.
+- Status reports of work already done β that's a `Log` entry on an
+ existing task, or the PR description.
+- Personal reminders / todo lists β use the built-in task list.
+
+## Relationship to other stores
+
+- **`.agents/memory/`** β enduring lessons that survive *across*
+ tasks. If a task yields a generalizable rule, add the memory and
+ link from the task's `related-memories`.
+- **Built-in auto-memory** β personal and ephemeral. Task files do
+ not carry personal preferences.
diff --git a/.agents/tasks/prohibit-automatic-commits.md b/.agents/tasks/prohibit-automatic-commits.md
new file mode 100644
index 0000000..ff067c5
--- /dev/null
+++ b/.agents/tasks/prohibit-automatic-commits.md
@@ -0,0 +1,92 @@
+---
+slug: prohibit-automatic-commits
+branch: prohibit-automatic-commits
+owner: claude
+status: in-review
+started: 2026-05-20
+---
+
+## Goal
+
+Make it a durable, team-wide rule that AI agents (Claude Code main thread,
+every subagent, every skill) MUST NOT run `git commit` (or other
+history-writing git/gh operations) unless authorization is *explicit and
+current*. Authorization comes from one of two sources only:
+
+1. The currently active skill's `SKILL.md` contains an explicit
+ `## Commit authorization` section.
+2. The user's current prompt explicitly instructs the operation
+ (e.g. "commit this", "push the branch").
+
+Agents must otherwise stage changes and stop, letting the user review and
+decide. This preserves today's auto-commit behavior for `bump-version`
+and `bump-gradle`, which will declare authorization in their SKILL.md.
+
+## Context
+
+- Today's pain: Claude Code commits routinely, even when the user wants
+ to review diffs locally first.
+- The project's `.claude/settings.json` already has `Bash(git commit:*)`
+ in `permissions.ask`. That asks the user per-commit but does not
+ redirect agent behavior β the agent still proposes commits constantly.
+ The fix is at the *instruction* layer, not the permission layer.
+- Skills that legitimately commit today: `bump-version`, `bump-gradle`.
+- Skills that do not commit but prescribe commit messages for the human:
+ `dependency-update` (already says "Do not commit. Do not push.").
+- The user accepted removal of the global `~/.claude/settings.json` hook
+ added earlier this session. Enforcement lives in `.agents/` instructions
+ only.
+
+## Plan
+
+- [x] **1. Add the canonical rule to `.agents/safety-rules.md`.**
+ Added section *Commits and history-writing*. Lists default (no
+ history writes), two authorization sources, the fallback behavior
+ (stage + show diff + stop), and the operations covered. Names the
+ `## Commit authorization` marker.
+
+- [x] **2. Surface the rule in `.agents/quick-reference-card.md`.**
+ Added one-line pointer to `safety-rules.md` β *Commits and
+ history-writing*.
+
+- [x] **3. Add a workflow rule to `CLAUDE.md`.**
+ Added bullet under *Workflow Rules* referencing
+ `.agents/safety-rules.md`.
+
+- [x] **4. Declare authorization in `bump-version/SKILL.md`.**
+ Added a top-level `## Commit authorization` section above the
+ Checklist: exactly one commit, stage only `version.gradle.kts`,
+ subject `` Bump version -> `` ``, no push/tag/amend.
+
+- [x] **5. Declare authorization in `bump-gradle/SKILL.md`.**
+ Added a top-level `## Commit authorization` section above the
+ Checklist: up to two commits (wrapper + dependency reports), exact
+ subjects, no push/tag/amend.
+
+- [x] **6. Cross-check the non-authorizing skills.**
+ `dependency-update/SKILL.md` already explicit ("Do not commit. Do
+ not push.") β left as is. `pre-pr/SKILL.md` does not commit β left
+ as is. Other skills scanned (see Log).
+
+- [x] **7. Verification.** See Log entry β all three grep checks pass.
+
+## Out of scope
+
+- Project `.claude/settings.json` `ask` rule for `Bash(git commit:*)`:
+ leave as defense-in-depth (zero cost when the agent obeys the rule).
+- `~/.claude/settings.json` global hook: already reverted earlier this
+ session per user direction.
+
+## Log
+
+- 2026-05-20 β drafted, awaiting plan approval.
+- 2026-05-20 β approved by user. Executed steps 1β6.
+- 2026-05-20 β verification:
+ - `grep -RIn '^## Commit authorization' .agents/skills/` returns exactly
+ `bump-gradle/SKILL.md` and `bump-version/SKILL.md` β
+ - `safety-rules.md` referenced from `CLAUDE.md`, `quick-reference-card.md`,
+ `bump-version/SKILL.md`, `bump-gradle/SKILL.md` β
+ - Literal `git commit` strings live only in the two authorizing skills β
+ - `dependency-update/SKILL.md` still says "Do not commit. Do not push.";
+ `pre-pr/SKILL.md` still writes a sentinel and does not commit β
+- Status: `in-review` β awaiting user sign-off, then delete on merge to master.
diff --git a/.agents/tasks/prompt-caching-org.md b/.agents/tasks/prompt-caching-org.md
new file mode 100644
index 0000000..71f0c4f
--- /dev/null
+++ b/.agents/tasks/prompt-caching-org.md
@@ -0,0 +1,165 @@
+---
+slug: prompt-caching-org
+branch: improve-caching
+owner: claude
+status: in-review
+started: 2026-05-24
+related-memories: [cache-warm-window, anthropic-api-caching]
+---
+
+## Goal
+
+Maximise Claude API prompt cache hit rates across the Spine GitHub organisation
+(~40 sibling repos) so that repeated session starts and agent invocations read
+from cache at 0.1Γ token cost rather than processing the full prompt fresh.
+
+## Context
+
+- Claude Code already applies automatic prompt caching to every API call it
+ makes. There is no single "enable" switch; the work is about raising the
+ cache hit rate and keeping it high.
+- The `migrate` script overwrites `CLAUDE.md`, `.agents/`, `.claude/`, and
+ `buildSrc/` in each sibling repo with an exact copy from this repo. This
+ means all 40 repos hold byte-identical content after a `./config/pull` and
+ therefore share the same cache entry at any given config version.
+- The `openai.yaml` files under each skill are FleetView UI interface metadata
+ only β they define display name and default prompt, not API call parameters.
+ `cache_control` cannot go there.
+- No GitHub Actions workflow currently calls the Anthropic API directly.
+- Current stable prefix: CLAUDE.md (β 900 tokens) + quick-reference card
+ (β 200 tokens) β 1,100 tokens.
+ - This **clears** the 1,024-token minimum for Sonnet 4.6 / Opus.
+ - This **does not meet** the 4,096-token minimum for Haiku 4.5.
+- The team memory system is empty; populating it will grow the stable prefix.
+- Cache TTL defaults to 5 minutes. Sessions more than 5 minutes apart miss
+ the cross-session cache unless the extended 1-hour TTL is used.
+
+## Plan
+
+- [ ] **Step 0 β Diagnose why zero caching is happening and enable it**
+
+ The Console Caching dashboard ("TeamDev Management OΓ", All workspaces) shows
+ no prompt caching in use β no `cache_control` blocks are being sent by any
+ caller. This is the highest-priority item; the remaining steps only add value
+ once caching is active.
+
+ Sub-tasks:
+
+ - **0a. Switch to Console OAuth on every developer machine**
+
+ Raw API key auth loses per-developer identity (`email`, `orgId`, `orgName`
+ all null in `claude auth status`). Console OAuth preserves identity while
+ still billing to "TeamDev Management OΓ".
+
+ **For each developer:**
+ 1. Remove `ANTHROPIC_API_KEY` from `~/.claude/settings.json` β it takes
+ precedence over OAuth in the auth stack and must be absent.
+ 2. Run `claude` β a browser window opens β log in with Console credentials
+ (the same account used at console.anthropic.com).
+ 3. Run `claude auth status` and confirm `email`, `orgId`, `orgName` are
+ populated.
+
+ **For the org admin (Alexander):**
+ - Invite the second developer via Console β Settings β Members β Invite.
+ - Assign the "Developer" or "Claude Code" role.
+ - They accept the email invite, then follow the three steps above.
+
+ - **0b. Enable 1-hour cache TTL on every developer machine**
+
+ Console OAuth users get the **5-minute** default cache TTL β the 1-hour
+ TTL is only automatic for claude.ai subscription users. Add the opt-in
+ to `~/.claude/settings.json` on every developer machine:
+
+ ```json
+ {
+ "env": {
+ "ENABLE_PROMPT_CACHING_1H": "1"
+ }
+ }
+ ```
+
+ Restart Claude Code after saving. This is the highest-impact change in
+ the entire plan β without it, cache entries expire every 5 minutes and
+ cross-session hits are rare.
+
+ - **0c. Verify caching is active** β start a Claude Code session, make a
+ few turns, wait 2β3 minutes, then check Analytics β Usage in the Console
+ under "TeamDev Management OΓ". Non-zero `cache_creation_input_tokens`
+ confirms caching is active. Non-zero `cache_read_input_tokens` on a
+ subsequent session in the same hour confirms hits are occurring.
+
+ - **0d. Investigate remote skill calls** β FleetView-managed remote skills
+ (the 7 skills with `openai.yaml`) make their own API calls through the
+ agent platform. Confirm whether those calls include `cache_control`; if
+ not, this may require configuration in the FleetView platform outside
+ this repo.
+
+ Until steps 0aβ0b are done on both developer machines, Steps 1β3 improve
+ future cache hygiene but produce limited cost savings.
+
+- ~~**Step 1 β Cache-hygiene team memory**~~ β *reverted 2026-05-25: the
+ batching guidance was too restrictive on `config` changes; removed
+ `.agents/memory/feedback/cache-hygiene.md` and its references.*
+
+- [x] **Step 2 β Post-migration cache-warm window (reference memory)**
+
+ Create `.agents/memory/reference/cache-warm-window.md` documenting:
+ - Realistic concurrency is 1β2 developers working on different repos at the
+ same time, not the full fleet of 40.
+ - Default TTL is 5 minutes. If a second session starts within 5 minutes of
+ the first (on the same config version), it hits the warm entry rather than
+ writing a new one.
+ - Extended 1-hour TTL (available in direct API calls via
+ `cache_control: {ttl: "1h"}`) gives a wider window, at 2Γ write cost per
+ token β still profitable after even one hit within the hour.
+
+ Update `.agents/memory/MEMORY.md` index.
+
+- [x] **Step 3 β API caching pattern reference memory (for future direct calls)**
+
+ No workflow currently calls the Anthropic API directly, but when one is
+ added, developers need the pattern immediately.
+
+ Create `.agents/memory/reference/anthropic-api-caching.md` documenting:
+ - Use `cache_control: {type: ephemeral}` on the system message block for
+ 5-minute TTL (1.25Γ write / 0.1Γ read).
+ - Use `cache_control: {type: ephemeral, ttl: "1h"}` for 1-hour TTL
+ (2Γ write / 0.1Γ read) β right for any workflow job spaced > 5 min apart.
+ - Place stable content (system instructions, skill definitions, shared
+ context) **before** any dynamic per-request content so the breakpoint
+ sits at the end of the stable prefix.
+ - Monitor: `usage.cache_read_input_tokens` should grow relative to
+ `usage.cache_creation_input_tokens` as the cache warms.
+ - Future: once direct API calls exist, consider a cache pre-warm job
+ triggered on push to `master` β calls the API with `max_tokens: 0` and
+ `cache_control: {ttl: "1h"}` so the first session after a config change
+ hits rather than writes.
+
+ Update `.agents/memory/MEMORY.md` index.
+
+- [x] **Step 4 β API workspace consolidation (already confirmed β verify stays true)**
+
+ A cache entry is visible only to API calls made with a key from the **same
+ Anthropic workspace** (a named sub-group within your Anthropic Console
+ organisation). Two requests using keys from different workspaces never share
+ cache, even if they send identical prompts.
+
+ **Current state (confirmed):** "TeamDev Management OΓ" has a single default
+ workspace (Environments list is empty). Both developers use Console API keys
+ from this organisation. Both developers share the same cache pool β no action
+ needed today.
+
+ **Keep true as the team grows:** do not create separate Environments per
+ developer or per project unless cache isolation is intentional. Any new API
+ key issued for a new caller (GitHub Actions, scripts, new developer) should
+ be issued from the same workspace.
+
+## Log
+
+- 2026-05-24 β drafted from codebase audit; awaiting review and approval
+- 2026-05-24 β revised per review: added buildSrc to migrate list, removed dependency-audit caching step, corrected concurrency description to 1β2 repos, dropped pre-warm workflow step (pattern preserved in Step 3 memory), clarified per-workspace semantics in Step 4
+- 2026-05-24 β added Step 0 after Console Caching dashboard confirmed zero prompt caching in use; workspace confirmed as single default (no Environments), both devs on same org β Step 4 updated to reflect confirmed state
+- 2026-05-24 β Step 0 revised: root cause identified β Console API key users get 5-min TTL by default vs 1-hour for subscription users; ENABLE_PROMPT_CACHING_1H=1 is the fix; warning on first launch is one-time approval only
+- 2026-05-24 β Step 0 revised again: switched to Console OAuth (not raw API key) to preserve per-developer identity; ENABLE_PROMPT_CACHING_1H=1 still required for Console OAuth users (5-min TTL default applies to all non-subscription auth)
+- 2026-05-24 β Steps 1β4 complete: three memory files created, MEMORY.md index updated, workspace consolidation confirmed; Step 0 remains in progress (Console OAuth setup and verification)
+- 2026-05-25 β reverted Step 1: removed `cache-hygiene.md` and references β batching guidance was too restrictive for `config` development cadence
diff --git a/.agents/tasks/setup-cross-tool-agent-instructions.md b/.agents/tasks/setup-cross-tool-agent-instructions.md
new file mode 100644
index 0000000..02672e2
--- /dev/null
+++ b/.agents/tasks/setup-cross-tool-agent-instructions.md
@@ -0,0 +1,138 @@
+---
+slug: setup-cross-tool-agent-instructions
+branch: improve-caching
+owner: claude
+status: in-review
+started: 2026-05-24
+---
+
+# Task: Consolidate Agent Instructions into AGENTS.md
+
+## Goal
+
+Move universal agent instructions from `CLAUDE.md` into `AGENTS.md` so that
+Claude Code, GitHub Copilot, and Codex all read identical rules from a single
+source. Reduce `CLAUDE.md` to a thin wrapper that imports `AGENTS.md` plus a
+small Claude Code-specific section.
+
+## Current state
+
+Both files already exist with real content.
+
+**`AGENTS.md`** currently has:
+- Orientation β `project.md` reference, link to `.agents/_TOC.md`
+- Commit and history safety β full rule (authoritative)
+- Other safety rules β compile check, no auto-deps, no analytics, no reflection
+- Moving files β `git mv` rule
+
+**`CLAUDE.md`** currently has:
+- Project Guidelines β quick-reference-card, `project.md`, `jvm-project.md`,
+ skills, TOC
+- Workflow Rules β `EnterPlanMode`, task planning, `api-discovery` skill,
+ commit rule (duplicate of AGENTS.md)
+- Memory β team memory (`.agents/memory/`) + per-developer (auto-memory)
+- Verification & Quality
+- Core Principles
+- Task Flow β plan writing, `ExitPlanMode`, `TaskCreate`
+- Final Rule
+
+## Content split
+
+**Universal β move to `AGENTS.md`:**
+
+| Section | Notes |
+|---|---|
+| Project Guidelines (project.md, jvm-project.md, skills, TOC) | All agents need this orientation |
+| Memory β team-shared store only (`.agents/memory/`) | Codex/Copilot have no auto-memory; the team store is universal |
+| Verification & Quality | Universal engineering standards |
+| Core Principles | Universal |
+| Task Flow items 1, 4, 5, 6 (plan write, verify, update memory, delete task) | Universal; omit items 2β3 (ExitPlanMode/TaskCreate) |
+
+**Claude Code-specific β keep in `CLAUDE.md` only:**
+
+| Item | Why Claude-only |
+|---|---|
+| `EnterPlanMode` / `ExitPlanMode` | Claude Code SDK tools |
+| `api-discovery` skill / never unzip JARs | Gradle cache path is machine-local |
+| Per-developer auto-memory | Claude Code built-in feature |
+| `TaskCreate` for live status | Claude Code SDK tool |
+| Final Rule meta-note | Claude Code session advice |
+
+## Steps
+
+### 1. Expand `AGENTS.md`
+
+Add the universal sections to `AGENTS.md` after the existing content. Do not
+duplicate the commit rule β it is already there. Resulting sections in order:
+
+1. Welcome / Orientation *(already exists β update to include quick-reference-card and skills references)*
+2. Commit and history safety *(already exists β keep as-is)*
+3. Other safety rules *(already exists β keep as-is)*
+4. Moving files *(already exists β keep as-is)*
+5. **Memory** β team-shared store only; omit the per-developer store
+6. **Verification & Quality**
+7. **Core Principles**
+8. **Task planning** β write plan to `.agents/tasks/.md`; verify before marking done; delete task file on merge
+
+Keep `AGENTS.md` under 120 lines. Every line must change agent behaviour.
+
+### 2. Rewrite `CLAUDE.md` as a thin wrapper
+
+Replace the current content with:
+
+```markdown
+@AGENTS.md
+
+## Claude Code-specific notes
+
+- Use Plan mode (`EnterPlanMode`) for architecture, refactoring, multi-file
+ changes, or lengthy documentation. Show the plan (`ExitPlanMode`) before
+ implementing.
+- Track live progress with `TaskCreate`.
+- Before reading library source code from `~/.gradle/caches`, follow the
+ `api-discovery` skill β never `unzip` JARs directly.
+- Per-developer memory lives in the built-in auto-memory dir. Use it for
+ personal preferences, ephemeral project state, and per-machine resources.
+ Litmus test: *would a teammate benefit from this next month?* β repo.
+ Otherwise β auto-memory.
+- This is living team memory. Update it regularly and keep it concise
+ (<120 lines / ~2.5k tokens).
+```
+
+### 3. Verify `.github/copilot-instructions.md`
+
+This file already exists. Confirm it contains an explicit reference to `AGENTS.md`
+at the repository root, a pointer to `project.md` for repo context, and the
+universal "Do not suggest" safety rules. Add the `AGENTS.md` reference if absent.
+
+### 4. Verify the setup
+
+Run these checks and report results:
+
+- `AGENTS.md` exists at repo root and is under 120 lines (`wc -l AGENTS.md`).
+- `CLAUDE.md` first non-empty line is `@AGENTS.md`.
+- `.github/copilot-instructions.md` exists and references `.agents/project.md`.
+- All modified files are tracked by git (no relevant "Untracked files" in
+ `git status`).
+
+### 5. Commit
+
+Stage only the files modified by this task. Use this commit message:
+
+```
+refactor: consolidate agent instructions into AGENTS.md
+
+Move universal rules (orientation, memory, quality, principles, task
+planning) from CLAUDE.md into AGENTS.md so Codex, Copilot, and Claude
+Code all read from a single source. CLAUDE.md becomes a thin @AGENTS.md
+wrapper plus Claude Code-specific notes.
+```
+
+## Acceptance Criteria
+
+- Editing `AGENTS.md` is the only required change to update agent behaviour
+ across all three tools.
+- No universal instruction content exists only in `CLAUDE.md`.
+- `AGENTS.md` is under 120 lines.
+- `CLAUDE.md` first non-empty line is `@AGENTS.md`.
+- All checks in step 4 pass.
diff --git a/.agents/widow-runt-orphan.jpg b/.agents/widow-runt-orphan.jpg
new file mode 100644
index 0000000..284b02a
Binary files /dev/null and b/.agents/widow-runt-orphan.jpg differ
diff --git a/.claude/agents/review-docs.md b/.claude/agents/review-docs.md
new file mode 100644
index 0000000..0481b24
--- /dev/null
+++ b/.claude/agents/review-docs.md
@@ -0,0 +1,18 @@
+---
+name: review-docs
+description: Reviews documentation changes β KDoc/Javadoc inside Kotlin/Java sources and Markdown docs (`README.md`, `docs/**`) β against Spine documentation conventions. Use proactively when a diff touches doc comments or Markdown, before opening a doc-affecting PR, or when the user asks for a documentation review. Read-only; does not run builds.
+tools: Read, Grep, Glob, Bash
+model: inherit
+---
+
+Follow the `review-docs` skill exactly:
+
+- Skill: `.agents/skills/review-docs/SKILL.md`
+- The skill owns the review procedure, the per-area checks (KDoc/Javadoc,
+ Markdown, prose flow, terminology), and the output format
+ (Must fix / Should fix / Nits + one-line verdict).
+- Scope yourself to documentation only. If you spot a code-quality issue,
+ surface it briefly as a Nit pointing at the `kotlin-review` agent β
+ do not expand the review.
+- Read-only: use `Read`, `Grep`, `Glob`, and `Bash` solely for `git diff`
+ and related read-only inspection. Do not run builds.
diff --git a/.claude/commands/move-files.md b/.claude/commands/move-files.md
new file mode 100644
index 0000000..25885f9
--- /dev/null
+++ b/.claude/commands/move-files.md
@@ -0,0 +1,12 @@
+---
+description: Move or rename files/directories, updating all references and build metadata.
+argument-hint: ""
+allowed-tools: Read, Edit, Bash(git mv:*), Bash(git status:*), Bash(git ls-files:*), Grep, Glob
+---
+
+Follow the `move-files` skill exactly:
+
+- Skill: `.agents/skills/move-files/SKILL.md`
+- Operation: $ARGUMENTS
+- Preflight (run `git status --short`, classify scope) -> Search for all old identifiers -> Move with `git mv` -> Repair references (imports, build metadata, docs) -> Verify.
+- Report: Moved[], UpdatedRefs[], Verification[], Risks[].
diff --git a/.claude/commands/pre-pr.md b/.claude/commands/pre-pr.md
new file mode 100644
index 0000000..24499cc
--- /dev/null
+++ b/.claude/commands/pre-pr.md
@@ -0,0 +1,32 @@
+---
+description: Run the applicable pre-PR checklist (version gate, build/check, reviewers) and write a sentinel so `gh pr create` is unblocked.
+argument-hint: "[base-ref]"
+allowed-tools: Read, Write, Grep, Glob, Agent, Bash
+---
+
+Follow the `pre-pr` skill exactly:
+
+- Skill: `.agents/skills/pre-pr/SKILL.md`
+- Base ref: $ARGUMENTS (treat empty as `master`).
+- Detect whether the repository-root `version.gradle.kts` exists. If it is
+ absent at both the base ref and `HEAD`, the version check is `N/A`; do not
+ create the file and do not ask for `/bump-version`.
+- Run the build/check command selected by the skill and
+ `.agents/running-builds.md`. The command may be Gradle or non-Gradle.
+- Dispatch the reviewers as Claude subagents in parallel β send a single
+ message with multiple Agent tool uses:
+ - `kotlin-review` when `.kt|.kts|.java` files changed.
+ - `review-docs` when `.md` files or KDoc inside sources changed.
+ - `dependency-audit` when any file under
+ `buildSrc/src/main/kotlin/io/spine/dependency/` changed.
+- Pass the version-check status to reviewers. If it is `N/A`, tell them:
+ "This repository has no root `version.gradle.kts`; a version bump is not
+ applicable and must not be reported as missing."
+- Each reviewer is read-only; do not pass it edit tools.
+- On any reviewer returning `REQUEST CHANGES`, treat the overall result
+ as `FAIL` and stop before writing the sentinel as `PASS`.
+- Sentinel location: `$(git rev-parse --show-toplevel)/.git/pre-pr.ok`,
+ format per the skill (`head=`, `branch=`, `status=`, `timestamp=`,
+ `build=`, `reviewers=`, `version=`). Use `git rev-parse HEAD` for the
+ SHA and `date -u +%Y-%m-%dT%H:%M:%SZ` for the timestamp.
+- Do NOT run `gh pr create`. That is the user's next step.
diff --git a/.claude/commands/review-docs.md b/.claude/commands/review-docs.md
new file mode 100644
index 0000000..f8043f0
--- /dev/null
+++ b/.claude/commands/review-docs.md
@@ -0,0 +1,21 @@
+---
+description: Review documentation changes (KDoc/Javadoc and Markdown) against Spine documentation conventions.
+argument-hint: "[base-ref | --staged | paths...]"
+allowed-tools: Read, Grep, Glob, Bash(git diff:*), Bash(git log:*), Bash(git status:*), Bash(git rev-parse:*), Bash(git ls-files:*)
+---
+
+Follow the `review-docs` skill exactly:
+
+- Skill: `.agents/skills/review-docs/SKILL.md`
+- Scope / flags: $ARGUMENTS
+ - Empty: review the current branch's diff against `master` (`git diff master...HEAD`).
+ - `--staged`: review staged changes only (`git diff --staged`).
+ - A base ref (e.g. `master`, `origin/master`, a commit SHA): review `git diff ...HEAD`.
+ - Explicit paths: limit the review to those paths in addition to the diff scope.
+- The skill owns the procedure, the per-area checks (KDoc/Javadoc, Markdown,
+ prose flow, terminology), and the output format (Must fix / Should fix /
+ Nits + one-line verdict).
+- Stay in scope: documentation only. If a code-quality issue surfaces,
+ note it briefly as a Nit pointing at `/review` (or the `kotlin-review`
+ agent) β do not expand the review.
+- Read-only: do not edit files, do not run builds.
diff --git a/.claude/commands/update-copyright.md b/.claude/commands/update-copyright.md
new file mode 100644
index 0000000..076fb61
--- /dev/null
+++ b/.claude/commands/update-copyright.md
@@ -0,0 +1,12 @@
+---
+description: Refresh copyright headers from the IntelliJ profile, replacing today.year with the current year.
+argument-hint: "[paths...]"
+allowed-tools: Bash(python3 .agents/skills/update-copyright/scripts/update_copyright.py:*), Read
+---
+
+Follow the `update-copyright` skill exactly:
+
+- Skill: `.agents/skills/update-copyright/SKILL.md`
+- Run: `python3 .agents/skills/update-copyright/scripts/update_copyright.py $ARGUMENTS`
+- If $ARGUMENTS is empty, run once with `--dry-run`, show the output to the user, then run without `--dry-run`.
+- Never add a header to a file that doesn't already have one.
diff --git a/.claude/commands/write-docs.md b/.claude/commands/write-docs.md
new file mode 100644
index 0000000..b9b9a74
--- /dev/null
+++ b/.claude/commands/write-docs.md
@@ -0,0 +1,14 @@
+---
+description: Write or update Markdown / KDoc documentation per Spine documentation conventions.
+argument-hint: ""
+allowed-tools: Read, Edit, Write, Grep, Glob
+---
+
+Follow the `writer` skill exactly:
+
+- Skill: `.agents/skills/writer/SKILL.md`
+- Topic / target: $ARGUMENTS
+- Decide audience first (end user, contributor, maintainer, tooling).
+- Prefer updating an existing doc over creating a new one.
+- Keep `docs/data/docs///sidenav.yml` in sync when adding, removing, moving, or renaming pages under `docs/content/docs//`.
+- Honor `.agents/documentation-guidelines.md` and `.agents/documentation-tasks.md`.
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..4fdcab8
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,68 @@
+{
+ "$schema": "https://json.schemastore.org/claude-code-settings.json",
+ "permissions": {
+ "allow": [
+ "Bash(git status:*)",
+ "Bash(git diff:*)",
+ "Bash(git log:*)",
+ "Bash(git show:*)",
+ "Bash(git branch:*)",
+ "Bash(git switch:*)",
+ "Bash(git checkout:*)",
+ "Bash(git add:*)",
+ "Bash(git restore:*)",
+ "Bash(git stash:*)",
+ "Bash(git fetch:*)",
+ "Bash(git rev-parse:*)",
+ "Bash(git ls-files:*)",
+ "Bash(git mv:*)",
+ "Bash(git submodule status:*)",
+ "Bash(hugo:*)",
+ "Bash(ls:*)",
+ "Bash(cat:*)",
+ "Bash(head:*)",
+ "Bash(tail:*)",
+ "Bash(wc:*)",
+ "Bash(find:*)",
+ "Bash(rg:*)",
+ "Bash(grep:*)",
+ "Bash(mkdir:*)",
+ "Bash(touch:*)",
+ "Bash(python3 .agents/skills/update-copyright/scripts/update_copyright.py:*)",
+ "Bash(./config/pull)",
+ "Bash(./config/migrate)"
+ ],
+ "deny": [
+ "Bash(git push:*)",
+ "Bash(git reset --hard:*)",
+ "Bash(git clean -fdx:*)",
+ "Bash(rm -rf /:*)",
+ "Bash(rm -rf ~:*)",
+ "Bash(gh pr merge:*)",
+ "Bash(gh release create:*)"
+ ],
+ "ask": [
+ "Bash(git commit:*)",
+ "Bash(git rebase:*)",
+ "Bash(git merge:*)",
+ "Bash(git cherry-pick:*)"
+ ]
+ },
+ "hooks": {
+ "PostToolUse": [
+ {
+ "matcher": "Edit|Write|MultiEdit",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "$CLAUDE_PROJECT_DIR/.agents/scripts/sanitize-source-code.sh"
+ },
+ {
+ "type": "command",
+ "command": "$CLAUDE_PROJECT_DIR/.agents/scripts/update-copyright.sh"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/.claude/skills b/.claude/skills
new file mode 120000
index 0000000..2b7a412
--- /dev/null
+++ b/.claude/skills
@@ -0,0 +1 @@
+../.agents/skills
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..aa71b2f
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,66 @@
+# Set the default behavior, in case people don't have core.autocrlf set.
+* text=auto
+
+# Explicitly declare text files you want to always be normalized and converted
+# to native line endings on checkout.
+
+# Common formats
+*.html text
+*.xml text
+*.css text
+*.scss text
+*.svg text
+*.js text
+*.properties text
+*.rtf text
+*.yaml text
+*.yml text
+*.md text
+
+LICENSE text
+
+# SQL scripts
+*.sql text
+
+# Java sources
+*.java text
+
+# Kotlin sources
+*.kt text
+*.kts text
+
+# Python sources
+*.py text
+
+# Gradle build files
+*.gradle text
+
+# Google protocol buffers
+*.proto text
+
+# Miscellaneous
+*.rb text
+
+# Declare files that will always have CRLF line endings on checkout.
+*.bat text eol=crlf
+
+# Declare files that will always have LF line endings on checkout.
+*.sh text eol=lf
+gradlew text eol=lf
+pull text eol=lf
+
+# Denote all files that are truly binary and should not be modified.
+*.png binary
+*.jpg binary
+*.gif binary
+*.swf binary
+*.jar binary
+*.desc binary
+
+*.scpt binary
+*.scssc binary
+
+# Encrypted files
+*.enc binary
+*.gpg binary
+*.weis binary
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000..81c8d50
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,26 @@
+# GitHub Copilot Instructions
+
+## Repository context
+
+This repository is part of the Spine SDK organisation (~40 repos).
+
+Universal agent instructions are in [`AGENTS.md`](../AGENTS.md) at the
+repository root β read it first.
+
+If `.agents/project.md` exists, read it before reviewing. It provides the
+language, architecture, role, and code review checklist for this specific repo.
+
+Additional guidelines are in `.agents/` β see `.agents/_TOC.md` for the index
+(if present; Hugo repos do not include this file).
+
+## Universal rules
+
+**Do not suggest:**
+- Any git history operation β `git commit`, `git push`, `git tag`,
+ `git rebase`, `git merge`, `git cherry-pick`, `gh pr merge`, or any other
+ command that writes to history β leave these to the developer.
+- Auto-updating dependency versions outside a dedicated update task.
+- Feature flags, backwards-compatibility shims, or fallbacks for scenarios
+ that cannot occur in the current codebase.
+- Analytics, telemetry, or tracking code.
+- Reflection or unsafe code without explicit approval.
diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml
new file mode 100644
index 0000000..7a51f8f
--- /dev/null
+++ b/.github/workflows/check-links.yml
@@ -0,0 +1,205 @@
+name: Check Links
+
+# Trigger only when the docs site, the link checker config, or this workflow
+# itself changes β unrelated PRs do not need to pay the build+check cost.
+on:
+ pull_request:
+ paths:
+ - 'docs/**'
+ - 'site/**'
+ - 'lychee.toml'
+ - '.github/workflows/check-links.yml'
+ workflow_dispatch:
+
+env:
+ HUGO_VERSION: 0.161.1
+ LYCHEE_RELEASE: "lychee-x86_64-unknown-linux-gnu.tar.gz"
+ LYCHEE_VERSION_TAG: "lychee-v0.24.2"
+ # SHA256 of the above tarball, pinned at download time. Update alongside
+ # LYCHEE_VERSION_TAG whenever the binary is upgraded.
+ LYCHEE_SHA256: "1f4e0ef7f6554a6ed33dd7ac144fb2e1bbed98598e7af973042fc5cd43951c9a"
+ # Force Hugo to write its module cache where the cache step actually
+ # restores from. Hugo's default on Linux is `~/.cache/hugo_cache`
+ # (or `$TMPDIR/hugo_cache_$USER`), neither of which matches the
+ # `path: /tmp/hugo_cache` cache step below β without this env var,
+ # the cache would silently never hit.
+ HUGO_CACHEDIR: /tmp/hugo_cache
+
+jobs:
+ check-links:
+ runs-on: ubuntu-latest
+ concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ # Detect the Hugo site root (`docs/` or `site/`) by looking for a Hugo
+ # config file. Outputs `present=true|false` and `site_dir=docs|site`.
+ # When neither directory has a Hugo config, the job short-circuits to a
+ # success so that this shared workflow stays green on repos that do not
+ # host a Hugo site at all.
+ - name: Detect docs site
+ id: docs
+ run: |
+ for dir in docs site; do
+ for cfg in hugo.toml hugo.yaml; do
+ if [ -f "$dir/$cfg" ]; then
+ echo "site_dir=$dir" >> "$GITHUB_OUTPUT"
+ if [ -f "$dir/_preview/package-lock.json" ]; then
+ echo "present=true" >> "$GITHUB_OUTPUT"
+ echo "::notice::Hugo site found under $dir/"
+ else
+ echo "present=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::Hugo config found in $dir/ but $dir/_preview/package-lock.json is missing β skipping link check."
+ fi
+ exit 0
+ fi
+ done
+ done
+ echo "present=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::No Hugo site found under docs/ or site/ β skipping link check."
+
+ - name: Setup Hugo
+ if: steps.docs.outputs.present == 'true'
+ uses: peaceiris/actions-hugo@v3
+ with:
+ hugo-version: ${{ env.HUGO_VERSION }}
+ extended: true
+
+ # `actions/setup-node@v4` ships with built-in npm caching that hashes
+ # the lockfile and restores `~/.npm`. We use that instead of a
+ # standalone `actions/cache@v4` block so there is only one source of
+ # truth for the cache key (no drift between two layers).
+ - name: Setup Node
+ if: steps.docs.outputs.present == 'true'
+ uses: actions/setup-node@v4
+ with:
+ node-version: '26'
+ cache: 'npm'
+ cache-dependency-path: ${{ steps.docs.outputs.site_dir }}/_preview/package-lock.json
+
+ # `HUGO_CACHEDIR=/tmp/hugo_cache` (set in `env:` above) makes Hugo
+ # actually write to the path this step restores from. The key hashes
+ # both possible go.sum locations so adding/removing a Hugo module
+ # invalidates the cache deterministically regardless of site root.
+ - name: Cache Hugo Modules
+ if: steps.docs.outputs.present == 'true'
+ uses: actions/cache@v4
+ with:
+ path: /tmp/hugo_cache
+ key: ${{ runner.os }}-hugomod-${{ hashFiles('docs/**/go.sum', 'site/**/go.sum') }}
+ restore-keys: |
+ ${{ runner.os }}-hugomod-
+
+ - name: Install Dependencies
+ if: steps.docs.outputs.present == 'true'
+ working-directory: ${{ steps.docs.outputs.site_dir }}/_preview
+ run: npm ci
+
+ - name: Build docs preview site
+ if: steps.docs.outputs.present == 'true'
+ working-directory: ${{ steps.docs.outputs.site_dir }}/_preview
+ run: hugo -e development
+
+ # Cache Lychee results to avoid hitting rate limits.
+ # Key on the lychee.toml hash so that exclude-list edits (e.g. removing
+ # an exclude pattern) invalidate the cache deterministically; otherwise
+ # stale `200 OK` entries for the now-checked URLs would be trusted until
+ # `max_cache_age` expires.
+ - name: Cache Lychee results
+ if: steps.docs.outputs.present == 'true'
+ uses: actions/cache@v4
+ with:
+ path: .lycheecache
+ key: cache-lychee-${{ runner.os }}-${{ hashFiles('lychee.toml') }}
+ restore-keys: |
+ cache-lychee-${{ runner.os }}-
+
+ # The cache key includes LYCHEE_VERSION_TAG so a version bump
+ # automatically pulls a fresh binary instead of reusing the old one.
+ # The restore-keys fallback lets a release-filename tweak (rare) reuse
+ # the existing cached binary for the same version-tag instead of paying
+ # for a fresh download.
+ - name: Cache Lychee executable
+ if: steps.docs.outputs.present == 'true'
+ id: cache-lychee
+ uses: actions/cache@v4
+ with:
+ path: lychee
+ key: ${{ runner.os }}-${{ env.LYCHEE_VERSION_TAG }}-${{ env.LYCHEE_RELEASE }}
+ restore-keys: |
+ ${{ runner.os }}-${{ env.LYCHEE_VERSION_TAG }}-
+
+ # We use Lychee directly instead of a GitHub Action because it
+ # must have access to the local Hugo server, which is not visible
+ # from the Docker-based action.
+ #
+ # `if:` gating uses `hashFiles('lychee/lychee')` rather than
+ # `steps.cache-lychee.outputs.cache-hit != 'true'`. Per `actions/cache`
+ # docs, `cache-hit` is only `'true'` on an EXACT key match β a restore
+ # via `restore-keys` reports `cache-hit == 'false'`, even though the
+ # binary is present in the workspace. Re-downloading in that case
+ # would defeat the point of the fallback. `hashFiles` returns an empty
+ # string when the file is absent, so this guard runs the download iff
+ # neither the exact key nor any restore-key restored the binary.
+ - name: Download Lychee executable
+ uses: robinraju/release-downloader@v1.7
+ if: steps.docs.outputs.present == 'true' && hashFiles('lychee/lychee') == ''
+ with:
+ repository: "lycheeverse/lychee"
+ tag: ${{ env.LYCHEE_VERSION_TAG }}
+ fileName: ${{ env.LYCHEE_RELEASE }}
+
+ - name: Verify Lychee checksum
+ if: steps.docs.outputs.present == 'true' && hashFiles('lychee/lychee') == ''
+ run: |
+ echo "${{ env.LYCHEE_SHA256 }} ${{ env.LYCHEE_RELEASE }}" | sha256sum --check --strict
+
+ # The v0.24.2 tarball contains a top-level directory
+ # (e.g. `lychee-x86_64-unknown-linux-gnu/lychee`), so `--strip-components=1`
+ # flattens it to `lychee/lychee` β matching what the companion
+ # `check-links` skill does locally and what the next step expects.
+ - name: Extract Lychee executable
+ if: steps.docs.outputs.present == 'true' && hashFiles('lychee/lychee') == ''
+ run: |
+ mkdir -p lychee &&
+ tar -xzf ${{ env.LYCHEE_RELEASE }} --strip-components=1 -C lychee
+
+ # 1. In the generated HTML, some inner links will have absolute URLs and
+ # the link checker will attempt to fetch them. That's why we need
+ # a server. Sadly, link checkers have no settings to address this.
+ # 2. Output redirection is necessary for nohup in GitHub Actions.
+ # 3. Sleep + `curl` readiness check make sure the server is actually
+ # serving HTTP before the next step runs Lychee. Without the curl
+ # probe a silent startup failure (port already bound, missing
+ # Hugo module, build error surfacing after `nohup` returns 0)
+ # would manifest 60 s later as "every URL unreachable" Lychee
+ # errors instead of pointing at the real cause. Mirrors the
+ # `pgrep -F` guard in the companion `check-links` skill.
+ # 4. `--port 1313` is set explicitly (not relying on Hugo's default) so
+ # the coupling with `--base-url http://localhost:1313/` in the next
+ # Lychee step is visible β change one, change the other.
+ - name: Start Hugo server
+ if: steps.docs.outputs.present == 'true'
+ working-directory: ${{ steps.docs.outputs.site_dir }}/_preview
+ run: |
+ nohup hugo server \
+ --environment development \
+ --port 1313 \
+ > nohup.out 2> nohup.err < /dev/null &
+ sleep 5
+ if ! curl -sf http://localhost:1313/ > /dev/null; then
+ echo "ERROR: Hugo server did not respond on port 1313." >&2
+ echo "--- stdout ---" >&2; cat nohup.out >&2 || true
+ echo "--- stderr ---" >&2; cat nohup.err >&2 || true
+ exit 1
+ fi
+
+ - name: Check links
+ if: steps.docs.outputs.present == 'true'
+ run: |
+ ./lychee/lychee --config lychee.toml --timeout 60 \
+ --base-url http://localhost:1313/ \
+ '${{ steps.docs.outputs.site_dir }}/_preview/public/**/*.html'
diff --git a/.gitignore b/.gitignore
index 3d7b70f..472e7ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,3 +42,7 @@ _code/build
# Gradle build files
build/
generated/
+
+# The `check-links` cache directory and Lychee cache.
+/.agents/skills/check-links/.cache/
+/.lycheecache
diff --git a/.gitmodules b/.gitmodules
index 2a7d81a..e7f6727 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -13,3 +13,6 @@
[submodule "docs/_code/examples/todo-list"]
path = docs/_code/examples/todo-list
url = https://github.com/spine-examples/todo-list.git
+[submodule "config"]
+ path = config
+ url = https://github.com/SpineEventEngine/config
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
new file mode 100644
index 0000000..f60c273
--- /dev/null
+++ b/.idea/codeStyles/Project.xml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ GETTERS_AND_SETTERS
+ KEEP
+
+
+ OVERRIDDEN_METHODS
+ KEEP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
index a55e7a1..6e6eec1 100644
--- a/.idea/codeStyles/codeStyleConfig.xml
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -1,5 +1,6 @@
+
\ No newline at end of file
diff --git a/.idea/copyright/TeamDev_Open_Source.xml b/.idea/copyright/TeamDev_Open_Source.xml
index 12ca8fa..cea7fed 100644
--- a/.idea/copyright/TeamDev_Open_Source.xml
+++ b/.idea/copyright/TeamDev_Open_Source.xml
@@ -1,6 +1,6 @@
-
+
-
\ No newline at end of file
+
diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml
index 7e1663a..0b8f9a1 100644
--- a/.idea/copyright/profiles_settings.xml
+++ b/.idea/copyright/profiles_settings.xml
@@ -1,7 +1,7 @@
-
+
-
\ No newline at end of file
+
diff --git a/.idea/dictionaries/common.xml b/.idea/dictionaries/common.xml
new file mode 100644
index 0000000..d1c3a7b
--- /dev/null
+++ b/.idea/dictionaries/common.xml
@@ -0,0 +1,71 @@
+
+
+
+ afghani
+ arraybuffer
+ aspx
+ bytebuffer
+ callees
+ closeables
+ cqrs
+ dartdocs
+ dataset
+ datastore
+ datastores
+ deserialized
+ dirham
+ enrichable
+ enrichments
+ escaper
+ flushables
+ googleapis
+ gradle
+ grpc
+ handshaker
+ hohpe
+ idempotency
+ jspecify
+ kotest
+ lempira
+ liskov
+ melnik
+ memoized
+ memoizes
+ memoizing
+ mergeable
+ mikhaylov
+ millisecs
+ multitenancy
+ multitenant
+ nullable
+ onclose
+ oneof
+ onmessage
+ onopen
+ parameterizing
+ plugable
+ processmanager
+ procman
+ proto's
+ protodata
+ protos
+ sfixed
+ stderr
+ stringifier
+ stringifiers
+ substituter
+ switchman
+ testutil
+ threeten
+ tuples
+ unicast
+ unregister
+ unregistering
+ unregisters
+ unregistration
+ websocket
+ workflows
+ yevsyukov
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index df7825d..7be402d 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -1,6 +1,915 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/live-templates/README.md b/.idea/live-templates/README.md
new file mode 100644
index 0000000..66713b3
--- /dev/null
+++ b/.idea/live-templates/README.md
@@ -0,0 +1,27 @@
+### Live Templates
+
+This directory contains two live template groups:
+
+1. `Spine.xml`: shortcuts for the repeated patterns used in the framework.
+2. `User.xml`: a single shortcut to generate TODO comments.
+
+### Instlallation
+
+Live templates are not picked up by IDEA automatically. They should be added manually.
+In order to add these templates, perform the following steps:
+
+1. Copy `*.xml` files from this directory to `templates` directory in the IntelliJ IDEA
+ [settings folder][settings_folder].
+2. Restart IntelliJ IDEA: `File -> Invalidate Caches -> Just restart`.
+3. Go to `Preferences -> Editor -> Live Templates`.
+4. Verify `User` and `Spine` template groups are present.
+
+[settings_folder]: https://www.jetbrains.com/help/idea/directories-used-by-the-ide-to-store-settings-caches-plugins-and-logs.html#config-directory
+
+### Configuring `User.todo` template
+
+1. Open the corresponding template: `Preferences -> Editor -> Live Templates -> User.todo`.
+2. Click on `Edit variables`.
+3. Set `USER` variable to your domain email address without `@teamdev.com` ending. For example,
+ for `jack.sparrow@teamdev.com` use the follwoing expression `"jack.sparrow"`.
+4. Verify that the template generates expected comments: `// TODO:2022-11-03:jack.sparrow: <...>`.
diff --git a/.idea/live-templates/Spine.xml b/.idea/live-templates/Spine.xml
new file mode 100644
index 0000000..369b72d
--- /dev/null
+++ b/.idea/live-templates/Spine.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.junie/guidelines.md b/.junie/guidelines.md
new file mode 100644
index 0000000..5160f49
--- /dev/null
+++ b/.junie/guidelines.md
@@ -0,0 +1,21 @@
+# Guidelines for Junie and AI Agent from JetBrains
+
+Read the `../.agents/_TOC.md` file to understand:
+ - the agent responsibilities,
+ - project overview,
+ - coding guidelines,
+ - other relevant topics.
+
+Also follow the Junie-specific rules described below.
+
+## Junie Assistance Tips
+
+When working with Junie AI on the Spine family of projects:
+
+1. **Project Navigation**: Use `search_project` to find relevant files and code segments.
+2. **Code Understanding**: Request file structure with `get_file_structure` before editing.
+3. **Code Editing**: Make minimal changes with `search_replace` to maintain project consistency.
+4. **Testing**: Verify changes with `run_test` on relevant test files.
+5. **Documentation**: Follow KDoc style for documentation.
+6. **Kotlin Idioms**: Prefer Kotlin-style solutions over Java-style approaches.
+7. **Version Updates**: Remember to update `version.gradle.kts` for PRs.
diff --git a/.junie/skills b/.junie/skills
new file mode 120000
index 0000000..2b7a412
--- /dev/null
+++ b/.junie/skills
@@ -0,0 +1 @@
+../.agents/skills
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..1268e51
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,79 @@
+# π Welcome, Agents!
+
+## Orientation
+
+If `.agents/project.md` exists in this repository, read it first β it describes
+the language, architecture, and role of this specific repo within the Spine SDK
+organisation. To create one, copy `.agents/project.template.md` (or the
+relevant language template) and fill it in. If `project.md` links to a shared
+requirements file (e.g. `jvm-project.md`), read that too.
+
+- Start every session by reading `.agents/quick-reference-card.md` (if present).
+- For specific tasks (code review, PR prep, dependency updates, docs, etc.),
+ prefer the matching skill from `.agents/skills/`.
+- Full standards reference: `.agents/_TOC.md` (if present) β consult when a
+ skill doesn't cover the needed context.
+
+## Commit and history safety
+
+**Do not commit, push, tag, rebase, merge, cherry-pick, or otherwise write to git history**
+unless one of the following is true *right now*:
+
+1. The currently active skill's `SKILL.md` has a `## Commit authorization` section
+ that explicitly permits the operation.
+2. The user's *current* prompt explicitly requests the operation.
+
+Authorization does not carry over between turns or sessions. When in doubt: stage
+changes, show the diff, and stop β let the user commit.
+
+See [`.agents/safety-rules.md`](.agents/safety-rules.md) β *Commits and history-writing*.
+
+## Other safety rules
+
+- All code must compile and pass static analysis.
+- Do not auto-update external dependencies outside a dedicated update task.
+- No analytics, telemetry, or tracking code.
+- No reflection or unsafe code without explicit approval.
+
+See [`.agents/safety-rules.md`](.agents/safety-rules.md) for the full list.
+
+## Moving files
+
+When moving or renaming tracked files, always use `git mv`. Do not simulate a
+move by deleting the old file and creating a new one β preserve Git history
+unless the user explicitly asks for a fresh replacement.
+
+If `git mv` fails due to permissions or sandbox restrictions, request approval;
+do not fall back to delete/create.
+
+## Memory
+
+Team-shared memory lives in `.agents/memory/` (checked into git). Use it for
+feedback rules, durable project rationale, and external system pointers.
+See `.agents/memory/README.md` for layout and write protocol.
+
+Review `.agents/memory/MEMORY.md` at the start of every session.
+Ruthlessly iterate until mistakes stop repeating.
+
+## Verification & Quality
+
+- Never mark a task done without proof (tests, logs, diff vs main).
+- Ask: "Would a senior/staff engineer approve this?"
+- For non-trivial changes: pause and consider a more elegant solution.
+- Fix bugs autonomously β find root cause, no hand-holding, no band-aids.
+
+## Core Principles
+
+- Simplicity first: minimal code impact, minimal surface area.
+- No laziness: always find root causes.
+- Minimal side effects: avoid new bugs.
+- Prefer early returns and clear naming.
+- Challenge your own work before presenting it.
+
+## Task planning
+
+- Write plans to `.agents/tasks/.md` before coding.
+ See `.agents/tasks/README.md` for format and lifecycle.
+- Verify changes before marking a task done.
+- Update memory if lessons emerged.
+- Delete the task file on merge to master.
diff --git a/CLAUDE.md b/CLAUDE.md
index ad8bf44..2ddd0b9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,74 +1,16 @@
-# CLAUDE.md
-
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-
-## What this repo is
-
-A Hugo-based documentation source for [spine.io](https://spine.io). The `docs/` directory is consumed as a Hugo module by the `SpineEventEngine/SpineEventEngine.github.io` site. The `docs/_preview/` directory exists only to render the site locally β it is not shipped.
-
-## Prerequisites
-
-JDK 8 (x86_64), Go 1.22+ (per `docs/go.mod`), Node.js 18+, Hugo Extended `v0.150.0`+. The `embedCode` and `checkSamples` Gradle tasks hardcode `embed-code-macos` (ARM) from `docs/_bin/`; on an x86_64 Mac, invoke `embed-code-macos-x86_64` directly (see bypass snippet below) β the Gradle wrappers won't work. CI uses `embed-code-ubuntu` (the `check-samples` script branches on `$GITHUB_ACTIONS`).
-
-## Common commands
-
-Run from repo root:
-
-```shell
-./gradlew :runSite # install deps + hugo server (local preview)
-./gradlew :buildSite # install deps + hugo build (no server)
-./gradlew :embedCode # update git submodules + embed code snippets into markdown
-./gradlew :checkSamples # verify embedded snippets match source (CI uses this)
-./gradlew :buildAll # build all included example projects via composite build
-```
-
-Bypassing Gradle (you must run the prerequisite steps yourself, or you'll skip what the Gradle tasks do):
-
-```shell
-# equivalent to :runSite β installDependencies runs npm install via docs/_script/install-dependencies
-./docs/_script/install-dependencies && cd docs/_preview && hugo server
-
-# equivalent to :embedCode β the script updates submodules before invoking the binary.
-# Use ./embed-code-macos on ARM Macs, ./embed-code-macos-x86_64 on Intel Macs.
-git submodule update --remote --merge --recursive
-cd docs/_bin && ./embed-code-macos \
- -config-path="../_settings/v1.embed-code.yml" -mode="embed"
-```
-
-Each Gradle task is a thin wrapper around a script in `docs/_script/`. When debugging a task, read that script first.
-
-## Architecture
-
-### Two-directory split
-
-- `docs/` β content shipped as a Hugo module. Has its own `go.mod` (`github.com/SpineEventEngine/documentation/docs`) and `hugo.toml`. Everything outside `docs/` is build tooling.
-- `docs/_preview/` β local preview rig. Its `hugo.toml` imports `../..` (the repo root) plus `github.com/SpineEventEngine/site-commons`. Edit `docs/_preview/hugo.toml` to enable/disable other doc modules (validation, compiler, framework) when previewing aggregation locally.
-
-### Theme and module aggregation
-
-Layouts/components come from the `site-commons` Hugo theme (pulled via Hugo Modules, not git submodules). The site is also a **documentation aggregator**: it imports docs from sibling repos (`SpineEventEngine/validation/docs`, `compiler/docs`, etc.) and renders them under a unified sidenav. `params.moduleOrder` in `hugo.toml` controls sidenav order; `disable = true` on a module import excludes it from the build.
-
-To pull theme/module updates: `cd docs/_preview && hugo mod clean && hugo mod get -u github.com/SpineEventEngine/site-commons` (or `./...` for all). Commit the resulting `go.mod`/`go.sum` changes β keep `go.sum` minimal (two entries per theme).
-
-### Code embedding (`docs/_code/`)
-
-Snippets in markdown pages are inserted by the [`embed-code-go`](https://github.com/SpineEventEngine/embed-code-go) tool, not written inline.
-
-- `docs/_code/examples/` β full example projects, each a **git submodule** of a `spine-examples/*` repo (airport, blog, hello, kanban, todo-list). The composite build in `settings.gradle.kts` includes `airport`, `hello`, and the local `samples` build.
-- `docs/_code/samples/` β a local Gradle subproject (included as a composite build via `includeBuild("./docs/_code/samples")`) whose Java/Proto sources under `src/main/` are embedded as snippets into pages. When adding new snippets here, you may also need to update `samples/build.gradle` and its build will run as part of `:buildAll`.
-- `docs/_settings/v1.embed-code.yml` β embed-code config (paths into `docs/_code` and `docs/content/docs/1`).
-
-Workflow: update snippet at source β `./gradlew :embedCode` β review the `.md`/`.html` diff β commit. After adding a new submodule under `docs/_code/examples/`, also register it in `settings.gradle.kts` (`includeBuild(...)`) and the project must expose a top-level `buildAll` Gradle task.
-
-### Content versioning
-
-Versions live side-by-side under `docs/content/docs//`, configured in `docs/data/versions.yml`. The `is_main` version may live at the root (`docs/content/docs/`) instead of in a `/` subdir β switching the main version requires moving directories *and* updating `content_path`/`route_url` in `versions.yml`. Each version also needs a sidenav at `docs/data/docs//sidenav.yml`. Full procedure: see `SPINE_RELEASE.md`.
-
-### Link conventions in markdown
-
-- Internal links must **not** start with `/` and **must** end with `/` (avoids redirects). The versioning system rewrites them, e.g. `docs/guides/requirements/` β `/docs/1.9.0/guides/requirements/` for main, `/docs/2/guides/...` for version 2.
-- For the version label inside a URL, use `{{% version %}}` (current page's version) or `{{% version "1" %}}` (specific). For repo URLs, use `{{% get-site-data "repositories." %}}` β pulls from `site-commons`' `data/repositories.yml`.
-
-## Authoring guide
-
-The user-facing content authoring guide lives in the `SpineEventEngine.github.io` repo (`AUTHORING.md`), not here.
+@AGENTS.md
+
+## Claude Code-specific notes
+
+- Use Plan mode (`EnterPlanMode`) for architecture, refactoring, multi-file
+ changes, or lengthy documentation. Show the plan (`ExitPlanMode`) before
+ implementing.
+- Track live progress with `TaskCreate`.
+- In JVM repos: before reading library source code from `~/.gradle/caches`,
+ follow the `api-discovery` skill β never `unzip` JARs directly.
+- Per-developer memory lives in the built-in auto-memory dir. Use it for
+ personal preferences, ephemeral project state, and per-machine resources.
+ Litmus test: *would a teammate benefit from this next month?* β repo.
+ Otherwise β auto-memory.
+- This is living team memory. Update it regularly and keep it concise
+ (<120 lines / ~2.5k tokens).
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..0a1b5f2
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,128 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, religion, or sexual identity
+and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the
+ overall community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or
+ advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email
+ address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+developers@spine.io.
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series
+of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or
+permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within
+the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.0, available at
+https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct
+enforcement ladder](https://github.com/mozilla/diversity).
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see the FAQ at
+https://www.contributor-covenant.org/faq. Translations are available at
+https://www.contributor-covenant.org/translations.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..2185ef6
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,37 @@
+How to contribute
+==================
+Thank you for wanting to contribute to Spine. The following links will help you get started:
+ * [Wiki home][wiki-home] β the home of the framework developer's documentation.
+ * [Getting started with Spine in Java][quick-start] β this guide will walk you through
+ a minimal client-server βHello World!β application in Java.
+ * [Introduction][docs-intro] β this section of the Spine Documentation will help you understand
+ the foundation of the framework.
+
+Pull requests
+-------------
+The work on an improvement starts with creating an issue that describes a bug or a feature. The issue will be used for communications on the proposed improvements.
+If code changes are going to be introduced, the issue should also have a link to the corresponding Pull Request.
+
+Code contributions should:
+ * Be accompanied by tests.
+ * Be licensed under the Apache v2.0 license with the appropriate copyright header for each file.
+ * Formatted according to the code style. See [Wiki home][wiki-home] for the links to
+ style guides of the programming languages used in the framework.
+
+Contributor License Agreement
+-----------------------------
+Contributions to the code of Spine Event Engine framework and its libraries must be accompanied by
+Contributor License Agreement (CLA).
+
+ * If you are an individual writing original source code and you're sure you own
+ the intellectual property, then you'll need to sign an individual CLA.
+
+ * If you work for a company which wants you to contribute your work,
+ then an authorized person from your company will need to sign a corporate CLA.
+
+Please [contact us][legal-email] for arranging the paper formalities.
+
+[wiki-home]: https://github.com/SpineEventEngine/SpineEventEngine.github.io/wiki
+[quick-start]: https://spine.io/docs/quick-start
+[docs-intro]: https://spine.io/docs/introduction
+[legal-email]: mailto:legal@teamdev.com
diff --git a/config b/config
new file mode 160000
index 0000000..56b5c90
--- /dev/null
+++ b/config
@@ -0,0 +1 @@
+Subproject commit 56b5c9070ad0efcadc3a96256e4c4937b0528e4e
diff --git a/docs/_preview/go.mod b/docs/_preview/go.mod
index 77bcc49..428834c 100644
--- a/docs/_preview/go.mod
+++ b/docs/_preview/go.mod
@@ -4,7 +4,7 @@ go 1.22.0
require (
github.com/SpineEventEngine/site-commons v0.0.0-20260522171914-2a606d89558f // indirect
- github.com/SpineEventEngine/validation/docs v0.0.0-20260522175555-cf09b4c706ea // indirect
+ github.com/SpineEventEngine/validation/docs v0.0.0-20260523180634-dd65da5abb65 // indirect
github.com/gohugoio/hugo-mod-bootstrap-scss/v5 v5.20300.20400 // indirect
github.com/gohugoio/hugo-mod-jslibs-dist/popperjs/v2 v2.21100.20000 // indirect
github.com/twbs/bootstrap v5.3.8+incompatible // indirect
diff --git a/docs/_preview/go.sum b/docs/_preview/go.sum
index cdf50f2..ee2e4e7 100644
--- a/docs/_preview/go.sum
+++ b/docs/_preview/go.sum
@@ -4,6 +4,8 @@ github.com/SpineEventEngine/validation/docs v0.0.0-20260205202311-181ba8844107 h
github.com/SpineEventEngine/validation/docs v0.0.0-20260205202311-181ba8844107/go.mod h1:STHyjXejVvPmfrxujfDvhofmjg55mMk+fwI3TVL0b4Y=
github.com/SpineEventEngine/validation/docs v0.0.0-20260522175555-cf09b4c706ea h1:YEMSlr5KJXkZdK7epMSnN+6HSr2K41n8O3c8OhtF8pM=
github.com/SpineEventEngine/validation/docs v0.0.0-20260522175555-cf09b4c706ea/go.mod h1:4RnP1hlrfBI7ZlTsJkllaOEyluhzmz4mOBrRgbc/tts=
+github.com/SpineEventEngine/validation/docs v0.0.0-20260523180634-dd65da5abb65 h1:gLlFsu7ZznIurecioKUHTP+sKD4EapeKFhtEqL/JvdU=
+github.com/SpineEventEngine/validation/docs v0.0.0-20260523180634-dd65da5abb65/go.mod h1:4RnP1hlrfBI7ZlTsJkllaOEyluhzmz4mOBrRgbc/tts=
github.com/gohugoio/hugo-mod-bootstrap-scss/v5 v5.20300.20400 h1:L6+F22i76xmeWWwrtijAhUbf3BiRLmpO5j34bgl1ggU=
github.com/gohugoio/hugo-mod-bootstrap-scss/v5 v5.20300.20400/go.mod h1:uekq1D4ebeXgduLj8VIZy8TgfTjrLdSl6nPtVczso78=
github.com/gohugoio/hugo-mod-jslibs-dist/popperjs/v2 v2.21100.20000 h1:GZxx4Hc+yb0/t3/rau1j8XlAxLE4CyXns2fqQbyqWfs=
diff --git a/docs/go.mod b/docs/go.mod
index fb6fad4..8d98efb 100644
--- a/docs/go.mod
+++ b/docs/go.mod
@@ -1,3 +1,5 @@
module github.com/SpineEventEngine/documentation/docs
go 1.22.0
+
+require github.com/SpineEventEngine/validation/docs v0.0.0-20260523180634-dd65da5abb65 // indirect
diff --git a/docs/go.sum b/docs/go.sum
new file mode 100644
index 0000000..455c4b7
--- /dev/null
+++ b/docs/go.sum
@@ -0,0 +1,2 @@
+github.com/SpineEventEngine/validation/docs v0.0.0-20260523180634-dd65da5abb65 h1:gLlFsu7ZznIurecioKUHTP+sKD4EapeKFhtEqL/JvdU=
+github.com/SpineEventEngine/validation/docs v0.0.0-20260523180634-dd65da5abb65/go.mod h1:4RnP1hlrfBI7ZlTsJkllaOEyluhzmz4mOBrRgbc/tts=
diff --git a/lychee.toml b/lychee.toml
new file mode 100644
index 0000000..6b7d309
--- /dev/null
+++ b/lychee.toml
@@ -0,0 +1,76 @@
+# Lychee configuration for the `check-links` skill and the
+# `Check Links` GitHub workflow.
+#
+# Mirrors the configuration used by the sibling
+# `SpineEventEngine/SpineEventEngine.github.io` repository, with the same
+# exclude list of flaky external endpoints.
+
+# Exclude URLs and mail addresses from checking (supports regex).
+#
+# The entries are interpreted as Rust regexes, NOT shell globs:
+# - `.` is escaped (`\.`) because an unescaped dot matches any character
+# and would silently over-match (e.g. `fonts.googleapis.com` would
+# also match `fontsXgoogleapisYcomZ`, masking real broken links).
+# - `/.*` replaces the shell-style `/*` so a trailing path of any length
+# matches (zero-or-more of any character, not zero-or-more slashes).
+# TOML literal strings (single-quoted) are used so backslashes stay
+# literal β basic strings (double quotes) would treat `\.` as an unknown
+# escape and either error out or strip the backslash.
+exclude = [
+ # Links that return errors during checks, but work for the user.
+ 'fonts\.googleapis\.com/.*',
+ 'fonts\.gstatic\.com/.*',
+ 'chromium\.googlesource\.com/.*',
+ 'chromereleases\.googleblog\.com/.*',
+ 'clients4\.google\.com/.*',
+ 'ssl\.gstatic\.com/.*',
+ 'googletagmanager\.com/.*',
+ 'x\.com/.*',
+
+ 'stackoverflow\.com/questions/.*',
+ 'openjdk\.org/.*',
+ 'npmjs\.com/.*',
+ 'medium\.com/.*',
+ 'levelup\.gitconnected\.com/.*',
+]
+# Deliberately NOT excluded: `raw.githubusercontent.com/SpineEventEngine/*`.
+# Catching broken refs into our own raw GitHub content is part of the reason
+# this skill exists. If rate-limit flake appears, prefer narrowing the
+# pattern to the specific failing path rather than re-adding the broad rule.
+# Existing `max_retries`/`retry_wait_time` below should absorb transient 429s.
+
+# Exclude these filesystem paths from getting checked.
+exclude_path = []
+
+# Do NOT check links inside `` and `
` blocks or
+# Markdown code blocks (verbatim/code sections are excluded).
+include_verbatim = false
+
+# Verbose program output
+verbose = "error"
+
+# Don't show the interactive progress bar while checking links. Both
+# consumers (CI and the `check-links` skill) run non-interactively, so
+# the progress bar adds noise to logs without value.
+no_progress = true
+
+# Comma-separated list of accepted status codes for valid links.
+# `429` (Too Many Requests) is accepted as a tradeoff against CI flake: some
+# providers rate-limit unauthenticated link probes from CI runners even when
+# the URL is healthy. Combined with `max_retries`/`retry_wait_time` below,
+# this avoids spurious failures on healthy-but-rate-limited URLs. The
+# downside: a URL that is genuinely broken AND returns `429` (rare) will
+# pass. Revisit this if false negatives accumulate.
+accept = ["200..=204", "429"]
+
+# Link caching to avoid checking the same links on multiple runs.
+cache = true
+
+# Discard all cached requests older than this duration.
+max_cache_age = "3d"
+
+# Maximum number of allowed retries before a link is declared dead.
+max_retries = 3
+
+# Minimum wait time in seconds between retries of failed requests.
+retry_wait_time = 2