From 9a082328587f6b3ff7a292d9594dfcdcd5c3f5fc Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Mon, 25 May 2026 16:31:10 +0100 Subject: [PATCH 1/7] Add `config` submodule --- .gitmodules | 3 +++ config | 1 + 2 files changed, 4 insertions(+) create mode 160000 config 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/config b/config new file mode 160000 index 0000000..56b5c90 --- /dev/null +++ b/config @@ -0,0 +1 @@ +Subproject commit 56b5c9070ad0efcadc3a96256e4c4937b0528e4e From bad98d5b12d24cda89e4867e43b3f1b938bf7c56 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Mon, 25 May 2026 16:32:27 +0100 Subject: [PATCH 2/7] Apply `config` --- .agents/advanced-safety-rules.md | 6 + .agents/documentation-guidelines.md | 14 + .agents/documentation-tasks.md | 20 + .agents/memory/MEMORY.md | 17 + .agents/memory/README.md | 89 ++ .agents/memory/feedback/.gitkeep | 0 .../memory/feedback/copilot-review-request.md | 35 + .agents/memory/project/.gitkeep | 0 .agents/memory/reference/.gitkeep | 0 .../memory/reference/anthropic-api-caching.md | 52 + .agents/memory/reference/cache-warm-window.md | 33 + .agents/project.md | 18 + .agents/project.template.md | 18 + .agents/safety-rules.md | 49 + .agents/scripts/pre-pr-gate.sh | 73 ++ .agents/scripts/sanitize-source-code.sh | 47 + .agents/scripts/update-copyright.sh | 48 + .agents/skills/check-links/SKILL.md | 320 ++++++ .agents/skills/move-files/SKILL.md | 57 ++ .agents/skills/move-files/agents/openai.yaml | 4 + .agents/skills/pre-pr/SKILL.md | 181 ++++ .agents/skills/review-docs/SKILL.md | 129 +++ .agents/skills/update-copyright/SKILL.md | 16 + .../update-copyright/agents/openai.yaml | 4 + .../scripts/update_copyright.py | 389 ++++++++ .../tests/test_update_copyright.py | 130 +++ .agents/skills/version-bumped/SKILL.md | 99 ++ .../version-bumped/scripts/version-bumped.sh | 276 ++++++ .agents/skills/writer/SKILL.md | 85 ++ .agents/skills/writer/agents/openai.yaml | 5 + .../writer/assets/templates/doc-page.md | 23 + .../writer/assets/templates/kdoc-example.md | 11 + .../assets/templates/kotlin-java-example.md | 13 + .agents/tasks/README.md | 128 +++ .agents/tasks/prohibit-automatic-commits.md | 92 ++ .agents/tasks/prompt-caching-org.md | 165 ++++ .../setup-cross-tool-agent-instructions.md | 138 +++ .agents/widow-runt-orphan.jpg | Bin 0 -> 54071 bytes .claude/agents/review-docs.md | 18 + .claude/commands/move-files.md | 12 + .claude/commands/pre-pr.md | 32 + .claude/commands/review-docs.md | 21 + .claude/commands/update-copyright.md | 12 + .claude/commands/write-docs.md | 14 + .claude/settings.json | 68 ++ .claude/skills | 1 + .gitattributes | 66 ++ .github/copilot-instructions.md | 26 + .github/workflows/check-links.yml | 205 ++++ .idea/codeStyles/Project.xml | 104 ++ .idea/codeStyles/codeStyleConfig.xml | 4 +- .idea/copyright/TeamDev_Open_Source.xml | 4 +- .idea/copyright/profiles_settings.xml | 4 +- .idea/dictionaries/common.xml | 71 ++ .idea/inspectionProfiles/Project_Default.xml | 909 ++++++++++++++++++ .idea/live-templates/README.md | 27 + .idea/live-templates/Spine.xml | 58 ++ .idea/live-templates/User.xml | 11 + .idea/misc.xml | 43 +- .junie/guidelines.md | 21 + .junie/skills | 1 + AGENTS.md | 79 ++ CLAUDE.md | 90 +- CODE_OF_CONDUCT.md | 128 +++ CONTRIBUTING.md | 37 + lychee.toml | 76 ++ 66 files changed, 4843 insertions(+), 83 deletions(-) create mode 100644 .agents/advanced-safety-rules.md create mode 100644 .agents/documentation-guidelines.md create mode 100644 .agents/documentation-tasks.md create mode 100644 .agents/memory/MEMORY.md create mode 100644 .agents/memory/README.md create mode 100644 .agents/memory/feedback/.gitkeep create mode 100644 .agents/memory/feedback/copilot-review-request.md create mode 100644 .agents/memory/project/.gitkeep create mode 100644 .agents/memory/reference/.gitkeep create mode 100644 .agents/memory/reference/anthropic-api-caching.md create mode 100644 .agents/memory/reference/cache-warm-window.md create mode 100644 .agents/project.md create mode 100644 .agents/project.template.md create mode 100644 .agents/safety-rules.md create mode 100755 .agents/scripts/pre-pr-gate.sh create mode 100755 .agents/scripts/sanitize-source-code.sh create mode 100755 .agents/scripts/update-copyright.sh create mode 100644 .agents/skills/check-links/SKILL.md create mode 100644 .agents/skills/move-files/SKILL.md create mode 100644 .agents/skills/move-files/agents/openai.yaml create mode 100644 .agents/skills/pre-pr/SKILL.md create mode 100644 .agents/skills/review-docs/SKILL.md create mode 100644 .agents/skills/update-copyright/SKILL.md create mode 100644 .agents/skills/update-copyright/agents/openai.yaml create mode 100755 .agents/skills/update-copyright/scripts/update_copyright.py create mode 100644 .agents/skills/update-copyright/tests/test_update_copyright.py create mode 100644 .agents/skills/version-bumped/SKILL.md create mode 100755 .agents/skills/version-bumped/scripts/version-bumped.sh create mode 100644 .agents/skills/writer/SKILL.md create mode 100644 .agents/skills/writer/agents/openai.yaml create mode 100644 .agents/skills/writer/assets/templates/doc-page.md create mode 100644 .agents/skills/writer/assets/templates/kdoc-example.md create mode 100644 .agents/skills/writer/assets/templates/kotlin-java-example.md create mode 100644 .agents/tasks/README.md create mode 100644 .agents/tasks/prohibit-automatic-commits.md create mode 100644 .agents/tasks/prompt-caching-org.md create mode 100644 .agents/tasks/setup-cross-tool-agent-instructions.md create mode 100644 .agents/widow-runt-orphan.jpg create mode 100644 .claude/agents/review-docs.md create mode 100644 .claude/commands/move-files.md create mode 100644 .claude/commands/pre-pr.md create mode 100644 .claude/commands/review-docs.md create mode 100644 .claude/commands/update-copyright.md create mode 100644 .claude/commands/write-docs.md create mode 100644 .claude/settings.json create mode 120000 .claude/skills create mode 100644 .gitattributes create mode 100644 .github/copilot-instructions.md create mode 100644 .github/workflows/check-links.yml create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/dictionaries/common.xml create mode 100644 .idea/live-templates/README.md create mode 100644 .idea/live-templates/Spine.xml create mode 100644 .idea/live-templates/User.xml create mode 100644 .junie/guidelines.md create mode 120000 .junie/skills create mode 100644 AGENTS.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 lychee.toml 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..b6882e0 --- /dev/null +++ b/.agents/project.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/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 0000000000000000000000000000000000000000..284b02a47d57121b3fa0356a6805428ad2030c8c GIT binary patch literal 54071 zcmeFYcUY54w>KOR1w}-wMq)e&_x3eFi33xvyDk)><=j&-`ZAoKBoBP_gPD zAkF}Qu`%EV005u^&`~i1&QK^S${&D=3vl*N8UXO1;{KcVqq_E28EQ%y835%IAV|rf zRH}c{j1>AUHOs%`L_z>Gf64ho`Tm{sDVb8kKfW~`;g6jJ?z+N#;NGtACjwep0im-pA(}jznu0B3bRDeJaZz=oVx=A^}VL*ia6QGQg zG!UQ$LOii|fI9gIJa%${c&H0*H=+dvAdc#S77E7F#!s}ITp{{_UQT9#ckVj`LLHPH z1wk4DY6uks>VkhM>F@6^8aQ5U4t{1262PwqinTz$O%7XIIQ!0XR4zxDkm zm$C8xHwO&%cWG~*drtpS{=b#)eLvud6Y!psH{93D!HH5_=$}krR?&t#`2McaKwDjqB1Os(;;160tF0+3t946GSyo9#Mn^$eQBLlroQ{l= zl8n6KEk*5r@)~$}``CLpIQ^3s@-JS^{}rzZ#rF^od-wlXzk;-`td^3pj^a%@P2F3{ z@|p^AvWhzLN;-0iItmImmHw<(Ma#>{-Use=9}ag{7yMt2xZfRyV%Kj=oxC7^PL8@> zaG1cKZKneHZ}eBXB`c$SOIK4?URwIM?YC~}=-!f1mQ|Lf^hqTh1;M{~9se7@{l$Ch z|CJX=QO40h#RuZ!?)2AC&~*3ti*kqj?uIJv_8u;jrjT@Wa<=z%_Yu^Pk(QT}R+g2M zQ3L+&>Fy9p`9Gtm zrR9~?pKI%co!wu1#C_7iAq6M?!yL-zKP>tW_ApWGp{AiZLqq%99x7^oN;KKeFg=gZ{SX z6a%<;hKkZ)XP5vWzzOzRUd$;#Y4AIB%(b`FF$ej?x5ve5^hY;HF6~yUFD0e+#LGEI zS%&z+B>P89Wl}W^=5Q$mO(DUB%Ld}R`8HvH<2(%JZ-0bJa^!S)=MTG#i=#ac^gvFY(@ z;@S39 z)05H4@4m4E=hhbZz*rM89nKL@O{{^85`?iruC7sZcKJO!dbx z*UIj-{b}xRlmGu`#?$Zr*>8-iXHG6i9@&s+7s_$)vXdbY6HQ=3IMNBu*nON@DEG(> zb-4-5avU-BGECBEqG|ZaZE-n+cx7UUcn3cd2nys0)*xve-FIV`%niPRKgl4lVEgkF zYDSg_ywjq)`a?^)0`wso3dtw)GB?)KA~bio$QuL{nR$-5L^{IP5i?0C1o}VfP@S0W zIA3)wN^3-VlnKOnn1`5;;L?Y`?F2Y{8+ISLGeYjYEG+uibJYV>Vg=viC)Dl|jq#^| zR{ks2mbOqdYcPZ{x<$^mpz-Wz>4-Wp-@*OX^1Q>@vg9bmQ$X%&EtDt$?t5>)b}Reg zZud;Xk;Xc#yai4UF4@d+zn=vw-#Dz;MZZLQbk+~=m4}@IDovt-;fv+Gr35LN=FlV=#*%Sg0+dFkC!JN&8`!;@a&w-yt2mD{*yN6cit`iA4^6UOXY z?d|+@WO4uZeORMu?d1w^QSIk)R4R>r*nX@QXlRs@Ipgt+%3S&|=h$PaDD_%gy z)}ffI=c4#{J1a<&KpG+1 z5K{L$Kp6Utu@;#zpBb=i<9D%I=Jce!sT7g1iVp_TW{Rbr?zKe6E}AFcE{B(OSwj31 z>m>J+h)bz(m#}ns3)_=SNe#QCQN7Gryr~Y`0{2-~(?9+vng4X?U)`TE1?|p=;pjjC z$Y*o zMe{R+a!(vGLz=QfQZf!^*Y=Et%=|TxZwVl_MUjH-O`rVlaF2wf; zAJCK*(Uss=v$K})5s{#4z`Z4!kIXZXEHnAzTUu3D&O5>^TGO$qDq=-d`JGPYqb_-g zGdFL0+)#Pr=)#7>!c6E5FWWIUB8j^(w$b9`y`K)ePF@47rnXlF|H> z&R09q4OD!o5Bh~zTd2TL&4A!dwS>#A8tN65D)H%W5wZI9;B2*ZQ%r^w4mY!SUg8ws zqIhyUSx9;Sfg-bP6LauLGHX7utns*{TAsRnVfE>+=1cUlSl5qgnq@OH#$_L6uV3qA zZu=&j%D8>?>a$6a&_xU2f*mzU>`TQ=`+PQ6x31AqL}LS(8&TS^e+d97V7jrD^pw-P zx4bAfdno?w!Z8XxY%yeq9iF*YOWdKjY<9J0R^qtw6tKWC8f;T|3TRVWrF@7l!Wj{& z{!@wvr+}%FN1>w$0tcabQ|QrMw9BmVbl_Y&5JPWFN+o1rIa(V-Zft#nQ`uNf)%^H0 zEunO4Ygjz_>y9Rm;e&!}mkpP{pV#YY`^?XB(hXuDg9#xmQnu@X)#CoekcJsz?8Q$E zvitXj7MUhyzQvuf4oD17h+5U3?&eIo^0wRrk7|W+w4ynhEC@*bQ3ZBX7xj-h$grAd zr*n0fs$-7G2CCsweYN{@8Q(cB^zo8o)DgK&yKNA2HwkcNj$^(y>VZtC{s1HS=chGv&T?Jf`XiY}k}G{B3JXQiHJyGd(+?@Z(@7H=bU< zFk|Lx;?t|Wi^Xiqr+|w{cl2c6B7dYWnmt%yeCfw#uxe5JhuKdf+gN+2CzaLSojkQa zck=_76Q;$j_nAfu@%l)p;{R5=(DLkk`vtiT=96QAZuusQ*8AN zlP`ifRhw_VU`5f+@p&8A>H_xwsa-DQlxFSLhG{K03zl?sbJESt58m@HUSu1q?6FJ{+%iAl0({^xVOSR&)6nmsNGgxe10nM8U}da=8v>= zVb}`GrqOW6WX%i47X;aNqrR!10y=CJC@++7HGa>&9WvzmZ@%pY;0fme!ZqVoQ@K4#t`N?_}bE}LZ=S@CUbi0tu+^VlpCdN>;D z+WWGX8>^itdzEjTz%gGG5h_Y%ZXq#!w4Iw;vpDoxOYnwEA3chGa9=6wMh|C{9jaf? zVo6#c$?``W17+r0s8-tyx%lks^Y(9UJJsaetqoo!Qg!26MKit~TlY*uhGcQV0*tIn z2zV?dR-N)KEXZ=9z9z9#O>Szsn-$;3QhX`7D_&jv8NM55gy-&@l^kngdAqT`e@LU` z&tdD-+%J8_=O8$?S8~fKTHv!0#3Wt0F4;un#6TKR6gq&eN)JOi%|-48E0K_%-kwE+ z@2%Y)el~j8^w}c{Vt(yoDei5I zm<1vNnE@Wq+owXdc5IBcVo` z_?zDwKhr%;i8-(^I1W4E{fVTL(zp!a6B)-P+g-+xau^KHjVv@&|B#rajV{fN>68#O z{(jEq2)NC=lvE|eUY>s<7c7V$?B1EX( z>$^k?b0_9@ab94){v)2(;2P}E=VzRpiuKKs??vISjx)2&kkHAaT8P&>-)*q*aFcmt zCJ9CCJG_{)6_y^zSakx#{%UT2X@w<|hT?48{%ydutUtIW1 zU?esi=d`erV)gLoc(p%ZTfRRf%Ratj_fLPK(7fDc)vA`x{=M&_jM|PKe}=fA(}>s# z=c0ySXjdpFDQkh3zWqdm5E=cgC{M2#9Oo$oLsomh_M-YNzTdVTD(&#r{e$|N&`#~u zsII$Q6$D!1b&?L@64b7C+D*lW-HH#ja1<1D{qe)asu5p$m?1!I!Vxy>^wBffubSvX2wVHauu)%L z-Ic?Wuh8=n+?&zP_L*1aom^rjfx@OdNMWz(rfbUu`t33bi)aSWd={t**)|t$D@oGI z?erDR#va{tmnysHr#PMjp7K!K;31l)4G7)BH5q%a-rjyL)N6WW0ag+Mnoo&reNy!4 zPN`cH@m-$bIDCJq6UP6{f5fbb(z{XkjX&@S;lc0)sHBl)1pyfS7C#~|QgcnJROD5C zQekzFoL4E|H=*QfeM^_2KNgP*Lg@_%$P?DvfrI4xvksa|A(^W^O7tF5m4UDRf!(I^Eaz{~m*bS>N}~yTu*ygiE!KpKltbJ%9LpPjJMNS-P;=FvUQQQq#6K}6 z!2Kv*)#=J90GhcfUq0b7?~!zL$0l^t?~^j&>@*JXIGLVP(5SW+tec~Z`7xI(!?^c& zqi5DA037dzo-9QpNoO90vZrzu-*GckP==K*`)+O{x@T&(oj3}IfMm|&PikifMy=wD zydGoQ?Rkrx{4FQQI0pTYsC!IN$?PzTgvhAI=S1ZFz z*m@VaITzA;1`6#4yhWy?T^*|s{S5NM%<1Q44tnNBc*WZ1Cixe-+oAlCDxuNH;yb8| z%qQw%IOeu4?H@v}*&c>%S>*aDrdJc8rj7o}-tpYN$FAn~df{(AJjlQPZb0f`S<+Hg zd>LqUz>M{an|C?a@$XLY;yftsESxhrwLn+ zd~{QxZ-c*jzm<`3BW;#ztR}LyrSYU=XFhp&6GXmm!E6P27MZe>u^Roo1SaFK-|}$f z?QuTZc`y=LL;L)r8Y6*oi!#jiuJ~RXNGb4=(@oY9matU@`KrnGUVIM#a@Y3^JelJ_ zI)DJ=N2D4jLga&S-)5=9pydm*YQe& ze<+lnlybtfwiTI$aCVOG>6DJ4yB*tN^dsRBW1WYQLy=kD$;cdy!ZyPipTwsz9xG^O zb>`XknZsM>yNWLK?^v0@C9bPCRm27uuypguY-Qk)@t}%#t1nyF$p(0rcH_`>%r;i> z^E+sAXNZIm)WO@u+i_eLW57^iEpBu+?~&n%U{CZXU3!;76j6w>er<0=(ohhZQF&o5 zZL|}?VWUs-^^d|)7fXjUOVE%;Z$)lK-b7jtlS~!O8L-#FXE>4s6`Dvp6TTV|=VES} zlKQdM^!{SW9ZAYe)(Z;wS$btpBa7ruplzkV8a0LjvLOlSOFU))*TfUMR{N5Hisvo7 zK>c`wr2VZqxCadSNROK*d}-->~%%?_?{x#m}VgH@{zt2u3^?jK^R zNJHb2vC(;~v5K2)8uKN}EQhiEURXQ{ff|V8(B8WocS#9H?r0o2_kz1s;m~g!G@VJrZuVPc@%#K=jcUu*AEX7 z!#)dFf*QQNwy#4z=AiuF#azq#%$R-QTHe$DN#A+swb=uS4JI3k!BwbTzp03?cxs=p%S97G@opinUEO@_N9y_PgfW zJuk(IuT>x%PM8?wnAeS9OF**8P^4VIQIE+UE!(z<&@m=kx#-=oM1IjCJSm}e&0#xG zeAi^5I|dCi)&Q|iyA8YX38EYET{Xq7#LTj6xB~=MVzsVxcdgbyf*r5eTS`vG1_=Ap zuM~c{6Pk9MPS(M~nGt$-n+Sa6SR2^AbtZ=H7$5oX@N*lg{>k!nV5ltNJZJo_hs@Pv z>0!EE>xqwr_fyhkA%3TT2=eC5A84&LHww6mXjsLG^W!*A0c_#K(x3(7d;1+k?6(k= zS#DK23Fk^K>u685kKQS=8AMJOpG20YhSX|eUqIX-==K#d3qkVb*zDC$g!owecH0KA z5q(L$p)R%nc1sBX# zGr=DxxiA-52?-p_#c~RLA}nESR+?e?n!csTHudGEL>o;f_@nP84NL9rJDrXXBwr>k z`ZyaNrs#||K|q_!m+I^Gi_7aGo0eLB_!NSNe2F%_>fch`=>w)d4O?kH-R^gNN`9Wx z@^GzSI^6?Jfs?d7<c55f12OS9%hV?%OVu~fwg>>8+#>lW-TIP~PLE=d08 z%4fj}s|O50M5w>4tH(x0z)r2LFq%`%;(ZU9Bv)*Sa zK%%g&1>E_gOr$|^!29SWIME?T{zdt=(z!`n5d!lRRwO=aJ_LEsj+x}L$)`vdv9=|F z+rv(FW{0884Ijef;|XsdX@o%d6+ zy|xQ)Nh6EYClgla=e`hypl!N7533%nqF#8BC2LKMGdMQmS2Z{S^0JgC4O4*HP%EtE z)6y3VP@wGNZaAN8xLefdP2RWYLRY81Y>^_FU?ZQVFOV@!hYFqJsU;AMM$WE*Y@e@d zv_v}m-8y?OtDz&amPl95Z4=>Q&(~l00_OSoLPs0YaRDC@cAcg(V8p#S)0TK~h{W>A zpvq&*jxlddzXmJ4Rkh9V-chlmT|J`X#6>fsYj28!9*97d_Jh&$XG&b4{JGT@%cxB! zUqV7BfAzWi#le^S_@sdk)(C#R+R;l@d5WnnP9G(4n{^GLDCw%&n3dUci2+{crJ`>u zVMM+`M>P}>bHw-|v@4XC;|uPiXqpRt9YQn@yk`SZdBI_^(_L;*>?S&9UIkTUTKS?e z0^5Djjk%rk$8ed!wQ(QTwAkg5!(Zc%AHaYP+cDzvs4LL9>cWKix#&=-${)VP!U>)1 z>?$@jE$MKzTCNL&jt${kSst}<3VUB_GuQ9$Y7Fx(YR8KTbc@-~XASbY%}S8q#Wtq^ zH>hHcNmSa#{jutYAAxNWL#0`F!e)h>cLjb%!&g5XRnAaQ`Lo{R~?~%liAHWnW83o6DZ8xrKioJ{GN__)`%O_8n2xz&DY{$>! zC6@PdDms^ts=E2Beams$Is8LY54lW;vl(zmkTs?I=5{ z$2MSVRQtF}WMsaNs)t9;=(|cYo(Q?!&n8JRzOC0Ej`lg7e{Tcbhr_=MnHlBu>V$@9 z5md27k)eF9Ir2k$peu+J!y1Bbbzs3^dHC$y!xY`LZsr?nwGBQTKpuZDv2CHsc%2LF zD{&X&D)7jTxoVe$8q|xxy4Wq>Qs%aec9pGstdYU5z94VKcWh%BY~eM8w8_kgsKQ3a zc=i`PW`34J3e`K1F12rla~gxjO=wo2d&rbdsNGLAE86Wn`3&L4y6$+5q={zph{vy~ zxlPE`2yE-#bz@v}1WF^p#b-pviOC|tFE1;+?kkl1_|2k8=+AQsQN zxZd0a^HOg~-dWX-mTLK$*ydwnTw3>-(;;oWqhzrsDL?sk;oSN$cxMI$;j4EWNVXHeoRBY-lbA0 zG<(b-{5XrRRN>hO-=1C)5hXn(|GZD`7!_3AVv!ZW-2nW;cn6K!`@^Wy>y#-D+8 zF~4j~?tYGk(;R2}`f-KqwzJlGwo5iQnMs@zkIi7m@}`a9ggbhkirx|bLbjxw4(^kT zu-lQLR3xW>Qm82eed!liosqI(pXjqK%nowed6ziz@LPp-lIydTn#;;(BaO4vv%2l7 z7~7FiIc$XS=VZfiIV;2|z^rBr=pVowz>#J?#!eihHlpv)*1J=e?$|5KspN4^Zh35W zkgBNZ6u`hX+Te%oM;g}lWT$t?%mE3P8rgmC-dU#zM#*PKMNKfkVIo#8Uk$Tjhq9dcs}==D-vthSs%;q zVC@nwF6{)L=>k=%pA>&2c>$?O>?Y)hr^xqA)FPt4+~9iOuiXDklOh<+Bx&K zM?xSKe}4z_<8U>h4~t@3zkXHiguuR|1AF!t34IG$h$=(;9aNA;{(Gq66F2s`Nut01 zcY32j-!LpR*3~g=_WDcMa<6<;!l!w>TT23AdqJxCiKKisxZkT`_u0}NG%y05etR@w zfU?Pu0Ig#}j+iuayhbSmoKzf%2CM$4fuFINoUvIOLoy0hMCwsymJYh)FA0)5(`gAv zE;Y9#!^9cw6m7NIvs`~nYH%ONlld1=QT`_yH3ZQ{c@Do>!J6m#Nt@x}ZwU{`R%!3? zFRG0ql8xSLM>%I2XFSr-im~mZb=p=v1+*Uo2D))11ubmjN{*DtrUdqk1(S2@<2$oZ z*-nPnpNDF|Hw%JduPe}jLVO{thGNw*2KExIju(QPa>UnT4v_7jO3LBQdBk&Lf+#Ae zSnbf}TZS)ludoRHnu;~%Hr3M?%v|Ru3tBl-XdVSe{+tmr`8)#4ncX77H2bUiR_+K| z+)uMe5~RMeXp>k%M>+nu_Pie=Jl&0we(2$dAFpYkM33ly@Lvzkh9AMmyx&iFe)4IM ziX=|>VWEQ$&@r>z;2C`#NWMNZ5o-yPe51p9W2mYr7eHH1GY<1TGC~;XkSZ3@ks118h2YhR^9Ly! z;L_0UlKHt8;`4K1by`Pmj7(Of82a9Uh(yBlEurw2c9Vx@lR78y5o)|DI!Q+74zdRB zfV3z}+h7vBlb?oseI6CZl|O?>|0CVzGwcqAR|CIAU|UKbPojDc*_o;pb|t7-PJKpb z32JlZmD)%2mG-aTf_yN8?;5Ab(M^Mu5+@45N~0P=V6yno_Pdrbsp|K>^(TE$Z}svC zDck#D{>|~CH7{>}*B3HRHQGF&D)YzI;Uw|!`La^JSOVj{`r7&+-$r{_(VU$G`_7%( zp*}C-liRg~rh!VO*Lk1KC$rPyv`N$VE%Y!a+ojODqa5(*2W%5)yj*RO z<5b%J^(sV+j<}+9d~_y9rZc=KJk_-e_I_=DZFywmG!LN#$)e1|)i&|FO{x_q02KyF@d5^LV!rGCu9(>KM*3 z5=^Nm?ia$gA5!IpoG5)8l6MzoJah{9Lr^_zNvB!MeOg?po4;bP{S?4My1@vrbHk+8 zW?!l(fo8<0s~DV&m(2CO?;%)QTN*1=X$tSqLrSeU7tK)8Jv3pXYW$4>>Z#;v|FxeW z#34)fEC`Qk=cn&oWaP3|uX$kx27APCdI$$>i^Y%4UR!u3oNOSP^KemHTR8b_z-kN{ z!v7-YLd{TFlc_J#6|+}0JN1W8sZN~}thZJsXV!(+;Qtg(dRI+?5Vp_)!#iqft zMmbibMdJ6Z>Y-3|eZkv(a3}u;`6Qd9+#ixZLU_-C3}UbdJCabD`YRf zc{QMLoEXa5exzl5WB`tK7~3jmxmzE*No3@ygA~QdILS4a*j-~^<(D&~W-q~lD z>_+qvSx6R($a8BN4C4yMx;o!|)lE!C%0!+UH6%|en2DRXRoG0JxZ}n3x#WZyZ(c(+ ztF0DhtI;;R$@zM6=NoYZI$`p9{}{9w+0N_GWd(ZrOI>Gsk0Hp&#-jrRwzdWv6t(zS zD@>azRO^$D=Q3_=FFJdPr{Y04A^d(9dkb?_48%jhj@`AEYx?e*Utru`wK*n?J;G8- z=S`^gr9Odpqk3HIrn0uc+s$A(d}Lb=6Tt}`TWy{pQZpo3O_%#MKpYAYGT^*$nRn8Q z@#Yk!$%}Dfb>X-PdFkWgXw6kPzH899YS^N0q-n)Q`bNG%01;T5n{CRxAh~rl#<3s9 z*OjPvIi6{TvYk|N5Quy|Co_xir$n^@vFI2Eyuap;)hrtZB-fduIX#HnnwNx9FFY78 z`346OiYj8dy`rq4+fGe`;m3*NK^?XX>DY5AW%;h(5l|CCaJsCPnqhLVAL<`$3|U+>#2%Ch9$c68H68LmXk!!QqYY`a((z@Bxp%=!AGFv}9>3FGmz6Ly4MtA;2EUINWrLFXb;+7A7nw{#X8I4wW?d8psW ze%Fc~s!t~!JBbVczFs|FpPf2l#`v^0IS4o~m$wGr6`S1*(ZO$Hkk0ern+x{~Q~cKIT$X41R+r3cwQXrB zhc|qrw(&M|Ph-ih66^qbJ2-|{YS6T$HfHMXt?zvzJyi)k&n6w2di{ATLL#rdVCC&@ zvx}O;S2v0KW9=%ABR3ny>IwpEU9uXF`0qE21kX&F@%c|S&AdKP+U#@iBs>H;pdyI` zvPxBg`HygPJGZ*xpn)Cm&TfX!mk)6ws}n7l2w!cZ7j^X!*HdJwgSh^f{XEb3qV^PU z3AQpq29>#qehu-zH*SkDb?pH^NW-pR$Dh3=yI?)p>q}Ig<`NDDndp!lodk=d^_aaqbonECv;wqRGu4sHK$YLmh^mo zRynKm5&1kR^2h-ZcGL~RS35Nh=L{N+<_OmhOSwdJo5pWFs63+Xd}kR_b~gNuL(Im# zfT73NZ?J2?C(P0Ols(yxh;kEx7v*dWO!Dqjfrr~?0YR9p8nC)7c49MLt+N4UAbIe$ zY*6Mk^(_m69!?8y)OyHcD>$ZL6fL>ZFzP-e@#|<~vOkk&cxv647z7S*W&#_~3n#xO zmw%4Xa13h6-+1tj&3LFKsWyELsXp4Wi~LdPjsOfC-*wrD(Fu8?iCuo`73n*O#W*ZUN3Uf%1WXsp6CJ25#V(^4=QV#|KN z&cf0(|A0ySN2r9G9a}I5cJ5`%Rg#N+aalFyQY7--AiT)fm$z-Q=F8`pnU}I!>OI!a z+~s7xUVii3&if%LU#|t+ zxirt^JGVX#prQ_VEbPMBGiimYu&q0~?Ph)rU9E6Z^1e4@0eH!6xyD#=&IAgxIy5%$ z6_;lp8*ATZYIVCUOPefs;1GS8@spb8;@}0c5|3$ChO*%*hBKShwTDyW=c!_!clSGh(Q~}jb5pc!Ck*(~w9k~Nu23T4ARxcPk9>&^+)sgt#}>qd$wo};cm2jzt1+BHq&AYg^RtSh@ek|{qn!uW;!nY(ZAUC)gtPg|v(qGM^3?zo5&1DJpKjer#(l6(7PXX0D zp`uv}CvRFfKXL3M@hJ$Kf+(wZKf#{m54nqjKMOrki=s(BXuRKA^-YUg_lgS?RipQX zvQnAV!rkAkFsxr zi@^37BdlDo_;8sA+&3yW1`DsUv>Aj@n{fAWpn`ZLq4X+O9SuY*K%%t$8Qn})`A<+4 zHCaLMay#RuNY+LaCxR9`-c^1%bkr&`+}+n0ZZ<4IwtTD$+T3NbVb zg4-7j=1(2?vqtn|Y7j9r_%>KIZ7s?)~CEzC>K6@ex{ ziqtpO#TW&-ZmphEb;;2mbD`0ze;$__8f17gs6-q>F1JOAE!U@IS|8MzP~s_=2v9Bj zIw^Fw!DA4OGu&+kup4!GV9q^DnwYD%^a=~;;5yR{@b&~YZtYByEsj=Xzz zX<>VFqSL zh{PyD@)?5I9rg3++HS>-RfBkeC#-HN`!Vm(V|(K0X!9ftWW2#9UnC;dY4KVqHkruy2!i|X-%#23e?&8s6#HiflYW7A!b+KD*SuI@ogGd3IA zs&SRTqc}EZtArl$ISOGk$vmSNN0iC664yXc(A$V`N9cb4n~U1IZKJ>Dy66XE4o%`U z1lMzDcYX3&IPmo&HTv?A&1ps`oVvBmmt^pyt%;lj1N-H6ue4Bqj9Qp*qQN$ZxkZI6 zL(+Rc;d-Ldl0#prd)-4Y2J>}mFL`zb8xPIsERYLeACf1W7t3&_>GErJA>n9xf$2;H!HXfJ$0IKel&I>*#R z_S(|)^LbTX2V%qb6E5=2COn#08=naZBlD}DL^|bSbk26=svm#kO8r<764m@Etly9i zGu3mX65QT*-p-ONb6iTMB8VCX={zhNQ7FB%(UEwZxiw2v4T@4z{$N~a;t59(OBlXP zC&?GPJ1ef(b34X6*B=*EABBi@TK%Z4t-v50zF^>S#*5R_A!!fy&iTjm?OwPU?f**f`#U2q(F9?2A}Ph#W<3s$U4R?NjFobU8Y zQzY0Zk4$w~JweNA$;)+o*MqF|f-*Q`zv}L4#y-)2xGZkJ#3n}$Mv&)dOT0sZn=l|^%IJyUPeb;Y8q9DeA0YS8lq%ixBOLE z$0LIv)?OYrSJTxzk79*D^EWv7Bq{OaTf$Lpff8x#{-HM_pw&#R5}&|2Pp*i3=DVMx zl^+(jA4Q3WzO+88M&O7Hz8>J>iv_Z2^2bR&w?&}ZlOT13huP|x1`MAos-?SmHt*Vm zWX^Qm4zNG!RsXt348w1S1*@Lz?skoQQB(cB2FYYQA!P7gk1gW4Fw;7Rj5WA3TF|Zr zT>YrPyC<1(TIjq`LS+-FeO?t5?T@CzfO~6>RrOw%JrRckdz7q0WuhJLNH7q;#CoOR*pv&+OIK z7yesIwRr?iNE5h|QzVT^1)TuGW| zr01`3v*m1zLobNcgBL%lmo_|9olexL1f{^$q+2)z9D7nWk3bmkJZ|;b9zTC6Zi!vx zPVS{tf66h~d+<7wopr!Ddb1*9kMu%Fc)#e)4&@}rTNyN5I5G!8uN8UOX9n~TA3VSQSyJ>jrCX}V+T53(KBxdrmI-FbG_f3b!wt^$cZ&|lTq(9S%ch5oeDDuZ4dss3 z>t94Vp8{AA{VzUh%oI)xuNX;46?~gaJ$6^${+i=U zgN7CsJg}pDO7$Lzn?ONpDR!t%_8fo`5sgu~=vN6jk%0z{Wtj{=V)vBFhtR|_CYxZM zlb_=__#E7iOt8o*l6B zx@r!B;jJxH2+`aFir-Cmn8tne>`O|dNg^RjVN#*v>B&6R76t4%5QGCG<3S4o6-UfU z(Pg1Mp$zBX{(uz6fHrX zl5W8WbF=BoUg8(Q#ayOl@A`Z`;-I*-!+0i(p>)c{PJ(gw*?D`DRk2@J$o20gPkPCe zFpLZ5@X|`5XA+L7(}5@Blm`x{+DDS<~zxjQpY60ncI zbKjnDvl5(;Y^u$1`uGM!grPH24!(vyqCO{B-^V{O_y^^FU!JQ5KcPJO7tT%_=2IRe zklB884+~zL8xRX-rT_4)Lp zTT4!&_irqEF;kVx451%WE=sKBP!W`#`X;b%WRgQAq5L@dxW^3adz1@zS8&*;V535A ze#zW$rgkv1Nk3g;nnE!5d&H6TSSa_p8LypnwGG$~oNZ+D9Xql;JSo&*3MlZ$ZaK$qeY>95T=!hBDVeh%>T4&njiRvDRRh_ldO3D_ET*CBdDepji>kl?pPloT z9tlHpQD}O3R1E6;9`HqrR7+#>-2KWUp7EiB>wf%aU|&Ojei5~x);vOqBgs{hodFh+ zl)aG8Wf10kp3|A(wMkB0IO|Npg0k}Z2kw#pV+$~NzkEonp%W65reG1*6k zsqE{7B7|g1g)C!V!XVjqMvNKzGMS;qFk|}OpU?07&iS76{m(fL&N27>x?b1wdOjbQ z>BW?|J{w&q<0ew4CmlAu^J!B%h}HOMBfEORPWL!WVeryjrQ$liE*bRh4}@vP-5|v% zpPQA#uu8lutt6IaO)>5s6wnw%TT}$VPa3< zf9M4!jtel4=nB38rJP5ROHIkCzo~6tcneSJSEc0)Gx0J=0W06IoIWG^ETGqOfl`W=$?$?%Ql@?*eI#r zfI-K91o+)&a4%JU2ZCBpsngLKgY3BJJVERz9P6cA5!i*GO^HUS-c1yp$IpMafXk%4 zD0_7E$>H?2C#Bu8X{>N;2dgJS>vn*&7XVa;GX6IJ2M&(q!t0ukAVwq5L`UK&H-t{d5P>)EHjYDIJIsFNCbNQ~oi`48o^qTvsQU9HEL0>Vg245A?OXh&x3vbnSz{}X5G z#mcnLqs99=Dt#cji~4r>@FHk7gt*f+FWK3opRCs*!9T6cyzRTi^|mrUDAY651qk?- z!xwj_IH%5BPLSR;PJ1?2tuM*y1$0D~7|L^^O_T0sl6m;H`)CfLQI{v3vSyX)-jwCb zH;R~PK1dRTl-}JdI`hJZV=ptg?MB3?X#4jsl@iFi$i#as64xh4uV&VT{JcZ7WlV!; zk7D>gZo=WsJpC8zt!14g?j~PMbJzb&CzCLV*-qQN704PQS9BlM8+ts<4C-QvxSaa- zev!e&0Hgiu1s*12S5N#ner9>7tIsa)s=lpx*gj@yy5BNWscvS5o<))CC8;FV?ub93 zC)!ea%3PtExr%u83@Ua$8TV~awzh|~;9nRh?e^`!syAbI#k{9sYj_six$h*d#L|Y) zVMb%FvHG*OPw|b(MsO zGq14k|Hw2QM_5D5QqXCk3PQIcIKDYRLstig5a2`MEb7X z*XqSuE{(upj>LespXFij{f*M)wmYjB*Uk0|5qiT{j5EY-R@R1Qhuj{3h9lu9xglickE~SbEY@&-j%!q zjF74cW}$Zy-}zn&%;qRgxSJ*~<|NH`SuMh)zMC+|*-NMfJkuV?St(rG8YN_|DBc6+yyRY#jIMs0Q- zG@G3yYB1lg5So_w#sKafqS19q=fpc->v8lumn$M-*Jc#&x(i%Optjh&%~z_Mb87yu zBn#&Sb%SS0yFfy^T%L;r9{PCqb^ET5TaJ>=lLd`6;YJKF^~9aSB&$9@PjwSH7&0L| z`=)O0%S*dU$Jnu3fHjM$MKZVQem?k@%(T(JPWf9Ivm;ZV<%}#Z7do*zA7`0PR9 zRh9SWjK7_`I6{{vAtaFLiw|;1N)bO6dzj;FD+$#Z$ItB;-|+Fxt8~} zo}=TiTTXl?JJ6zTS{UOd`Ev9JZ2dUt+ivHqVXF)_)omcpgWS{FMMQTXS={S~z*8Xf z)m$RFup3QF)WlX}$n`=sU$<`LcwI{2mQ89%!CzTAS`+ILQHR8fHk@b%4wc`)qtFjs7b?8Q2l3 zG>`4>54BM?Yx(Ahb|zo#wv5vhikgMa zXYxYTYCTT#3Z*$bn1k~EV=Jt_5t^5}=wLwmhy`AJu;7z!?gU-aAX2!8xRx0cgFV~WE*6%UntTJZM&YI#SR-*Md!8DVJQ?80MrC+(6R`7jv3)i(30N= zAJNeC&tu=w{|{g%OWg^d*bxBB=L2#;Lbo-OJt=EAJ9KPOOPWHwbW`}fF7u{WcX4dT zJthnr@2WXJly!-}((6Rx#w!pdW8iS#znqLLLt3kR%+r*yPTFs%hKZ%E%uAQLSEhcV z9SS0vq4#)lXDSSE`PJ=FsQc|a1pr&fONn)PVRkd~84j*F>n$i)7yQ~`HWM4ncWRuR z<2IOXtpE*@(?-LqSk4iOp-#kmm3cUdg{vK_tuy%AJg8yvn)&BtJ6s${d(nnzcB_lac zgRa&TIJJk=R8q5Uc7N0TOheewLLkYE=W5MK4tvJgHB>mm7R5z(U)rHt>nl#E^wj4( z-c|gQ{NTWVz+pFaL!rbB3u!fNczKw{B^hOeQBHp(&e2tzlHHP1N z;Cifv;3ndd3ksmUlzaeG2Mry z`cPq83OcrxcalPQ@n;@w zKUzpxhzub{0sYQ|{XA4?%eOwbrnYYE!(LlI$DXsvYEtR%B8i(>;TuB80TI~Eik7Gk|IN99H?=^kU4 zqU1+)6-Z&BwV9N*^u^%|rbLA>h>loR#H9Xul#>g#4$CE#TEfVn_Ep_>nK_9uMc}Of z4g$^hS+4K>po_P zFm; zaxIa!kBarV=s6_pT8D$ARW_Js#$2zqN-ufY8u(k-1d-L3Dl#wOZrL|I6M$<-=1hny z+tT`u94#r5`9pLD|B`Za6sFJVyMe{8`$ z>Fu{iSmWr^gxk})w}ZmM-6dls#&ovA$O;c@T8!GA#Y;0i{ovvQ1h3#-qUv3e=?zi$ z*DqBPK~Qj11p8QEvTL`NcJhZQVnb71y|z)w^ego*@OoXAUM-w!lEab57l2ozd`W! z^CF@=3hyZdR!wv_E5ihshpD}uv}ff6H97|ndNnHP1PEhLz4#cqw*={yD zX2`x$n_6gq5-T++Le)1*a|UUV3=p&i5yZ9!ftpXu6VWMvUGdTY3mXjc!9sM_!<#?Iu^y$2LL^ zHEQhp5DJcV-YRg#GGxy$L1)>ba~PqFg9oOkUcUMEZNET194+rzz2$a(Nmqqo7%>R6 zzH?|kB*-#mxD)RbqmJf*$YS5(R1H6Rf4K&QpQ`RGXAt2sesji>?;AK)URg*6QrhFy z|FH>sJwwKtrfiCZCC~BE#z;MWRSDv>{fFJ^s7kRy`y}3u({1MTAO3O-VMzc2q9jXt=1i;x@pL1r2QMOWo_ z9*M)JIFBB-4Z?|YkLMNHuM%@z%zX#m|92}a{$Br<;Bg;BcAa|utZV0?R*xSwqt|Kb zw!^(WYjfe&Qku5|VYb)Z@6z!J``muvbgo;t=&C3k1@CX)n;Du@*-qmR0NeUyn_HUz z>{1yixpY2VuARbyG=DjzP`w$(*>9pO(4in(TW^7@SG_L6cgKOd@^Zs4MnWrxn9r1M zyGGfG^$8;IY(9UlFHs%VAL+hqT&CSZ*f50$UGC{w&Oo98^oONyUc-w%7S)%3K&E{9De6fB%RI}I2xG&zRX#p*ULdI`z{e0HWiH35q z_Xs|ZNMHKmPT7yI)F7L6$(yH`s<>!pWL@0n`>JJj?)Jy9C+DbriqF!bq%WH>n%aP> z6%CANf?4XUi8}B(d*lPK2X6-@7=$(pZ1IMIaSwSx*rNBxn zCFx=mn=TL=>a4yb-Ds7x+UM)p1lCh3crvil(tA$u83b~h4-gZcbtX={uW|{0)$;!C z(~?Q8pphvl0FZEs_cg~5VJzMIA6p0@1AvySzo%c+XD?QkVI4s|e{uicpvM2@B1CQj zcNRnvfK0WK+`a14F4wi{c9GuR z(LR^6ls!Msb&C?I|j>#saZcU*IbG^+8+I2^Z#n)ew)NY_nhh~!!CqN>RV&&z!m z^4Hg%as1W9GCY7p4->lN6dRI=KB`$Xx4J#HLyo#4YDJQ-P8lQ3v?NsD79$v?zmJXk zf{Il=$Tw6;Y2laqx*&dehtdEN9!z{s73_q8FAtHU3Oq z>*2YFj7Vi_v=3^~FO~(Tu1MBkaQ&4Dd0eg2%D@nhAX1E_)+auu;MBUDuZbb$@PcMJ z-`xzZp6vCz9LT^lsnff@0~o)QHtC2tZONB^L443)8qIvk>=skr#XZpyYp%RIh3{Fj z;tt%>!V|56yv=LRrkXSA-rkU61ayIF*&|U_fRa&93&(DvI~{^P&CVXg3(p*S=B|nO zNqt91)%g(eZluhwMFB)CE&9vQPaCWg)bm;8zo2{vTBA=Ky8LO?zXNBraoaBN(QjQ( zSI%t1@-)KVlwP%*51;GB)^BIv1>jha(%1j#`4XH2K^`iJcCEC6665d^ltu%U?-xDP zS4mk{r|cKrNoY)^C~eh)HBxoGKz$T4e0 zbA@BTSu#16)RWqO|76r_X+NU=D!QzseTwm@v@ick@~Rl}=e-DA8(?wEH=~m4J6o-A zhlceDDvvbUFiP7TXtlQ?r**9TZg5rAJj>nR;$T}7!*G2!8qVMD4!W+=&A^;~`S zBca!Q;l3}7%*=c$-2Xc{da;`OZ~PGvGSOq@zk1l-+}>|E2iMw&IE+=vS|c2122oG+ z?&qntQ+=|L<9~*E9D$^nVDN6Y+HF^<_`FqH`L}MV!_xgJ8crxG@lcY?MFo0y0sYG; z>51B{o9V>A8O45|=8ZCW4$7WiZUp?;v%kR2t2K;!4h^6!;t?U^Jo}^*H%kR2Pp{(Y z?(J;90C2EUMx=6Uzs#uIz2mUpxObv$eojneD{{lWJfF->iCH2o6i}2IcD3KAhEcDL z1)}E%TWo}4NHgartNw8N_JxV+!{%u zo}owYC3)nZPW?ha2H>6>VZ{G_#4EiIe*WH?&onIHEG$HG^L6-V`^e9inUMot-*Dj_ zOK5x|QL;-XT*ZQ({Hdd0K7piiE$mY#_ySZJ z*O<#6!3VYA5L=U+ohD6{k>K*gQ%U&LDYwbMqaY2=(w01^;b3Iv*R<%IpP>Tp3gZhy zk}&DcLlL+f(tc+cI*u15pWiKd2U+6x?vF?73-9yi02{Wq(-5dJxrnI0s5Bxa@H+@H?dhdZl zbv)>nY-hVEbsx9-PH%oWs{kLDQR96G%8qU$5etf6rfR-va<9upd>ITzJmoPi zoj=I;YzYk^3+{zlhWe*h(KI5!EM`h(HZ{5!&$&aX9&YdgJ}Cp;bqTp>L> zt?`p>mFjp*Jb5K=MnB)D#(?k4j!$q+&Be6d75ih^hu|JSzGLw+Ra(bu+v6Rtj_K+J zeHDV72?E+yQ{^Oc(T0~EfogE5E4xQG+Kv?UHfm z5nHw{d2ZaAvRWpN+ZUX6RHO>5X3EdzDoe#IcnKm1%?46A+f+F=5sCaF*0g9{0Rtg9 zTQRlvpk*ub!$g`H1UX@r#2{bPGMk^%_= zX)xgopNJ8CATb^x%zQA-9_u=xf5|ITbQ>RO#|c*+I7;CB;-Fj3!g^*E@z;dDT5mLw z*(GBh(Y7#&3tD3!Z=LRGtWNkfvG$nYrt->IW%tk@-k~r?A9mNcdwB3ZJo61VfR{W$8xQoF#WzGONM2=jSN|0cI}(I5(m$cR3f z4BFWI*7X)OWAU-OXE|qO0#=K^-Bq2Q&BubelVOYb9buJmc3ca&rC`C*LR*d1O0j-- z*!kkm_$R}D+Qu5q7h**4;J;-*cl{h})z?B+qe0@V0`3k~!gM+= z*T31lB2}pCyD=@W)^>&7%Fvum-DJ!DE|w&gDnm={t8fHZp575clg0QxsVsw2 zjpO&%wNTX39e%O2^4iVY_b^>b@Tnsu-*|M_S-dY&E}mfkTe#sA&AKv$&AIaDAVPXG z`(#r|rUC?NH(0A}R3lK+Q?C2HR~By|+B0ravZ?xQ?WwRfH@TfO2qD$u018V4zQ6k_ z(VQEnU6NlqYam|nao_IL+N|hLlX1(ow=MgA7yZrPZl>maVlt8a41y!QkkZ@PCNtI| zM#AuJJol}v$ZdirPcC2owQ%ueBGRF2uBd~x(po3z%z6Maoum~P*v`2@^-f>k z2>9ZyX)33esV>dw`d3S>G~E5;n(O1I({Oj3c9+I?+TmePJf2JC1}Pjy>!bE0 zGW32iEhsy2y7Ei$LO9U}Z}3ymCsU!C*PXgxGUfi6*>4_-2$al3ZSs6PpoHcKt7(-m zmFNcm7}}$eQVGT|_znWYMtE`1=Xk~{NH-1B^WDDz0)PHojpBWc&##u2Qhf2m?(5sjkEXf}X8*CBUWoWS zHLIeZbKxT+Kck*d2TEzXY5?T_E2IAE$mvT^Vz8gQrl)u~br@!ewg@H7!E<)S^F1r7 zJr9>tI?dOg7{*9At%JVr#5qb*W4qAjWFjt)|DCtoGOTM(GrN__q+Bpm>Xgs95gPIA znyF_OGrGn>wjF4l_U&ku#4x7|P`=FwiTKh}8xqd@15(}m)3Nkn(EOho_q+K80o zje?H}3Z0t&Ki!L~Y@SMf$+`f?u%4rr3jeXif9mV#&Hl3e7)fKi$!XsJ2CW1RfC&3| zR+u6EfAh=#w`q9QcAeFUSLx2@bEMCr43yKq4lbF?cUw5z_7yHKjG#)Q=(1;;0XDhy-DRqyz+YpVE_Gj0_FR$w>aAlYGM<-CA;H z)z$YYHrF+WQ8|ADXex)B0Q#y_8wn<=-KZlga<7xx}OIv{ql@s9cK+Xs?)(#D;%1i z>A56FecaRT3@gQGbJo7)8{7O%6wf2EeoLhWTlB)YYNdTG)rZ&z(OhVseEjLX#mYq8 z@Fj71lc~SuD#X%Q)+Ojvwo-X_wcxbTSHq%@q~8ITqM+;DBfYuXY>T2}z;%|KOXatw zgSeA5%m`c?qu`}0EjJggyfaizguBtqLTJr#U$L-;M>&y4?_X+Yf%TR=++;r(+MG(+ zU&L<%LcCyh=sz}V(f1+xuK~1pGmNy8kj4ayR^taXzYvd1KMEGiR7BF!zGa~%WlVfc z*(ICTVg@~csI2XTt~~WQp%=)xD3tS?O=^%~55~uE-N?*5sq}$HAuZrC+xBpov+#b=HI4#r$`G6{g&5cQvb}1A7^=WW}Qg`zn&fjQdXYid@8>cKvgH3fu{p8 zZ1oAc0)W!X-?J|l)G|?lg)~@``b~0`$;^^!jA!<}2lwW0?;dN`Q~&m4gQPN0sSY;?qZ4N)9l?!esqUkAlb^8f`|=7UoaQQ1cZC;DD0VvG-|rVw*-?S8QI*UH+~tM z*MwKia>6U}n{ZEj+e!OC2q}@s1PL?E3-HiPyk{3#e`JG}@A2kWPEHQ;w`^0>`}tnp zx;h&*U1w=oo>i~IX;q>m9=2qY(k8fWxA5xlxVPPOvQSr*_i5e`ccsiXyM5YoGv1l1 z@vd%O&$6TXTD8P;T0X62PA>^q@|(Gje{T0pa8i!+m9L{m(p)k2bS&V+wA6Eh{6ra- zYz^{Y`_(q^Zimn6)DAunTlVCbem{L~4f>%0XP=uB4+=!@^IXa`!%YFMz(3(S-J-rZ zC2|?G{g0pvn;CZH9nI|wx5Q`8X~QlT>Y^I_#{|tV7ko5!hMh7wB6Z z<4_ByIeXiWbyey-bMICfljR;U%vbYI47ey>GF4tQkh>x8I*nJVd~G4uc1 zk62p89AkB|6h|V2>4`*4iY^RRKIh%WOWBF^$y2(BKg#ayBCSggC5PQ?NDBz;rno%b zR{c&JW@8Qb(fLVTC`$)7`x+-?!ag7YxzC_UXr-ha-C>RPiSW2{+Xwr9%-=KT0irUs ziH=@8;_SAui%F(|d6Ad(!tOP;zJjQbxy;DcBYhVH$stm^xR9&z8EHQr@hk)w?@9V0 zfCV7dXe^P=`PmT)nytT{?hf;)a@gd$Y|B~7a8>?TGaKlV=aEg(4n=QVwoTI}@YI|x zzSSz4dAIJKikWi8Z3)L15ZRCcVp`Cbs0zkdN<^V&p_f*~IjU#O-!F5`QW5F63u%ka606*Zw{}s3&hfV%{Yt#miiyFEwF)b3qO<)m{1Brk4jmm3zN;rKVj7?S{q01HUMLhwdRKd_;P$yM z72R$uk@~4Q>kR7_RczC*pYY*BQMt3nuD1852iI@huR!gup3SW?N=gVb`1?kXfW$=FFnfd{%yX z3QKqCx-y<4(>t_5F+m_P+l@S-CTd7j%dPS!(53>vKzv?l_(jp=G?gqdQ`!}e6E6d!*lWkhaLL!rJk!k1}CQ&T^3I#6SIiyTIWKnA3YOpvRSEKwQtp++iYT)##g)Z4I_rp9N5ZNXf78z;xj<>R<(7Q?}Msa zmwWZY19XN`@0IHkmkp#&8_q~#=8I8(mxgD#Nm>aJyxx)=fszsx>2P!O9Ebe%K!OG(JtF{5ST>zRZnA13y{v*NBIEGP`M-^7jH&Q`5@@Dgtj6E3!iu6XJh|IyRxg|(Y%!|>1B>?dtB)hd+V zG+xsPZwl?Lf18qCev1BNiE(Z84-KNKXW-~+L_&8GlQP%5qH(vJDH=z#0 z@6PQZhqbS@(I-2;b&Y{M&@Y=xzk^O~=7~&UvC|bn4j%h9VbKYzdL`sH2W|_FPQPwB zv`3i!%J-&g(X8q7MVsv+EmD-74t=!=ra5WQ_R~on>95HeHi;jh(N&Tg zz_p!ey*mpjx)%4J8^;CVc?z1`8&3VQm>JK!UHA)XlbJ2_qD8*KTXmzx;SM~g96>G* z_mp{U|8*qJdO;~QUeHZ8Drug*F!B46Td{d_xynaK+hcmU_LIQ}V)_3um5wk7eF3KR zWSh)}zKl>+zkHl;?HAaOof3xla9m|iq0Y^4lR0&$<}wEy?&WZ1PoxNZot9rCxjk1F z(cjJm*XO5093m~ol!7>v_wAuu;$4P*B4Kg$kim=p<cX!#LEa%cQzF3g~>)`tH%FE_=2i@hf6kqbeZ#g3ePAP|&u1pU& zaie{A5+mg42&!lIYXZ^=XTxHrhZfR@rnoLoZ3zDS$Mz8OByG)X;_;4qs7x(u2y;_# zTzoC?;rnB6zReu|nM-f-);eg_4)jsE5Yas9log?U)bv+{ei!v{i94#s>u@N!m`kak~e|iFGWcXCuN--Ro5NRqcMluXDS{QXdUjG`3pE8UC3qc@u zo7$~WA!_A0Uo(c=e-Col${oLz$@hV;@0b=$qZbwUg*0elr-yeGzp-32ktofOfTd;i$f zd>NAe58_&3N?!wj@qB$gxR!z~2X#$7S1uNOt29*{?s-xvK<}%N-v@;5yR5WlCj-u` z-uxx4s_t(p85^O`9p33C)jb})xbR)b((~AB+gy5sqA>4@=PiNV()fRD zu4sPbR+rs!-Zd)9pj&%7A#mqHc=Lmr=5N&r4w_T=pEnJ^YQJvmf_xA8k=ZBz&fqwo zdP?gJB4K+Rws=dI-S4ON856bnraea*8>#SYd_d5qg@-l0d zDO^U>yw>uS4FY)dxc>|xlJl5=yBM_fTcAT@FRzFgBIEhH>*_?V*?VwEJv|Vh{ zM_#}0md|E#A>z0BK#oGKQ?ewdb50jNqOo!#+p(DRPCDQ0TT@#tB&Xn#<;k;Y4f&_O z56In>FSr!xq>Q?Cl#DW8bP`rB4kz{N%nO-Q78;4U*QWGjKeQweY};N@OhbYZZjdY~ zo|8WzU8{US7e*#|5!tVgdTGxC@k^}j`f4_}= zvM0H$Q2lN^ev0$k59ttu;#cC0O9rblk&<%e$&e+jE;Q%dR#Rq*Mj|)&3^L3j(Y&!` z0`Zj?Ih2QASiKGz);D!;{7?G5u@s~82EU=qJ2Ynm|CZBvRLIglHopEZ2H_?2@h6dX zuSVf^SGQLooG^~j-@5y@quoVt38|>=jFWw-DImL}9OtQ^LD-Xq$ww8HOAhiqmo@9o zU4~!le^3iSW!+NrIr++7d5}I6?6&)f*jk*k@6&dqa`+~viaUOo4n2U1oE^p*?(dSWNK27v=swFy}{ARyCi;!gAj zosu=Z@YQ+MNna`du_0Hn<%zq<*HL0xf?a;=5;8{^rr}cl3Hlc>H8atIo{nS5j4|&a z{`8x*Y7cQHiz^n^CBr{XnD=znnXcY!HRZTvgmH7$E{^<8xR>oX7dT$r3g3X~C+u#A z>jC{zB9VJmU5r4#7l@Ybejq*S#t3Q~G@TQMXc7`%`Cq?0=&$Kii|IImE!Bc*@?-uc z!mV(<_b0GspA;*U))>d3A%dD?-N^W>*&Gf#OS>bzKmK=y!cRnOw@Y?qQXG~Ks~zo; zkNtX#x<$(~%5@wFIemhQU9x$&t8k7We>Wxhfq+NP%yI~`IuV*1%gu;( zqp*1lvWmV($uO7itUuh3Qy&xrAS+Y*qK0pO7r4ncF-#KOf>%XW5?1QXMLqDQsr@BA zPk-G1$HvW411$J9NiFb2(76G~L(2Itr&EC^*m!?hG`ylvtcQ2Dr8@JD?hl@W)O+tb z-I#u8U@U(Svj?VFXS7}OtfTBi*Q%WL{?;$BYxoK8>XPvY3Az zg4jFjY&taX4A!M9uh#Jmj`0v@Zl@P#cy-p5@!DN?bxjpH$4G%$$qe9 z{!ugb_92ECrT25mS=l|J+?&q97KSTLKmNF0C)*gT-`_?JiB>0Y_mT$9tZM7WR(ZBt zGLU2Q(OuUJ>MT3J?}lDpkMRFb95$#Mw4?$^xaKgG1WBG2gTfhOB4jJk1k2fKq<~aX zagQ&^<`6G7X(1@Sc1?e~^#b;RTHn<}FQ9}xqT5i9lVvg;#$?A4zqegr?t99YR893- z;EKcNg|nnj+g$D4;Sl?>GRzaveOU2@Qu>`G@IToD66TDz*7J$O&hAk7`GPXH=%$$W~2mdzUB`^N%evy$<+hOWRIQw|QIKVNWl~CN@YV4mhB3 zrb;7Y1yk06GgHHG-O97@XSj?3x1(S1UX5bECBiYhx20FT-F#F?s)K59Y(wy7=ADoaUCT)jnHTX-KIWB4f7)cU{gHxb~r+MgJorc zp6X_yZQTG}CeV`aATK7} z<(@65u{{TQ=a!t!k~xZZo?=P^iI-V=hMCZ0kjnYZI`GLG-!szb!JIc;tOe_X_jCiq z{sX^NKc96V=6|}PH0JxBKf@TV9XJN;KVxnEZxMS#W$)d2sn1VI$)}#^cGMUN9Avi< z`x>OsdT&Zhr=Yg&Y3?$w>F%qDI}whs084rqrQL|?k-I4Fgv)lQX#RRB9Xwvr@>2Qt z>)59w{|!m}ch~ab2asPY7pfh`5XE-`uY6YoSM$_TSngRcuY+lzlE-WF5Yo*`zR=h9 z^S>Sx+tG{$GWkvAb&6yYt>J&{sbEh;3h zmaE}nU{8M8KhuJ=@2h6f4mlMTQ8MOo^Qne1B{%2B)ScpiB5O)2H~8KrPCcE2F5i&W z!iQsjsm(*qy=1IEOziHHL@J^7CAWLO9$~sg#e3rl;2;NT#oHx?$B@&u<@w+VA1p_C z(d%Dt-`$^b=DU5-(E{}*Q=p6}`Qm@J$l<^Oz%5K)zP1KC?cAoBeLvWy+*#o71Pc{X zf8DICPwZ!T^B{xoNqAA8v{}aPfR*+WqnxBx{gstXJunQ|F3R79K8xZ;FYO4bzZ&YW zx_UM=-CVJv%HX7F&xS|V*p;5pkmD`Q-}U7v`URvxD^g}c%dJmHe%xU`4INWz9$|7h ztQHGP{BouokKbJFL1(kVV-}3lL%o()j-UU>w#42AaZ1eRMxeMgmYaOW0ka%5^bD75 zIXM{@5*%{2^jJ$yoNKx>k+w*I^`IV8RF)($Es|XI$-3~;U-ZNW(|^bz_ZuS(y#nW) zzNtw{Z#yBv;!l1zYsE992v^w5W3GS3)YPtc90iZzl|V%!rnSQXP4pUIE(Vv=A@D;j z>e~Hj&!48zPHEMv*7e5BsW3JKV#ZRJBfCw3W8CQ+D(LHGo)`rb_ovm0`7y5 zYRDe!?~|B|BL#aspcmc0ezEZUhv{gh!co>=ZZ_jZGYk)mx+TunP6w%adJP+z6^i~ ziDd9!@IRBjZGpcsx5~zSFGG?88nFkjpLTkH|HIjDKt8LGOb=gF;wHvMZZo-vjeo^* z6eMr-r&~`{V#9j&+@O%b)(OwXrkr!j@>vSo_b)s19GkUCx4a4paeV~9rq>L5BXb23pmOQ|SuevqP|3j>b-X!-h9iKjR2ZC{ zb&Y4}5+q!Q#oNQzR#Q5o75V4rzfE2phXcPSoW)l*XnHZG@8VmgcKZPoV@P)LG{NrHN?#642yoMFu&= z^CL^|k1=(MVM5v>lfdpI6GhKPtoiS!4SPusqWz5rxPSaQXY!Z7mQae#qqb4} zwCU)MN^QS`uHwEsoLp{a5Eg(a%SkkqPV+eWl^w`dh;wQ5;L870o1c<@GHB=2Jkw}| z>0Gct4+Fn32>N{c6U5a0+tv-2Bca}J5FvK@8ff5RG>KkT5FXC;a@@7ubHC|y8%tpmFPPtm`0YGZKR zob>VA6Sd-(JQ$YP3AjfMR!q=|Fq9_z@o26P)g58UZD8X{pQQN0t?rIGQ+*Sr+vHv< z*CJ0HHD>;w_TDp`?LYqi?Jh-C%~G^x)vEoim8v~c6eUJ&YNV|dgtWCOidKq}mZEB; zMiM)n)JTj7iG0uJd(L%k{LlZK8^8PK#{0T*xpH0NBk%QkJ)e)~ooB`h zxkTB5f~EWH+(K8};_GWI*KBn4l@+`EvUBa)ZtF_~?10I()7342D$@`@z`*%vE~HGdBSG z?&(x`s!zkW7Z%;O^dmAy;%^6%ADGU>D*J{=;&Nj)$|rK}xcle8!PJ)}!bhe0!U5AR zW%p+OUika4$Xg{#r!AF)%jMhNK9zgzqADlve(IKo5K{*@zC3~T^yAOPUxCe9p^PR> zlNTvRzbi^#C9SH6%DS=90}bUiSuG{ObJrliU-v68UM2@`V5w#mS_s`ep3B9C<@PfNEw~#;&|Eu_j?+1g>8O?`94w1nAcMYCTVGM35q2&UNjkexIzw zd&I5~+HZ#Y<`PfE*4ilQ2b9Q`z$RZPm9t&RQELt$XE}2Q$~4t;u;8?9qMQ1z$CXDr z`his04bQp&6gK}ULwj{!J7T2h<9ugPM2G@UR~+>EpK0MwC2|mZwVy`t4`p8iGeRqF zqXKulRZ5CzBS)p}<_s;mhcQ(@JLWWbvF)V1#@zla z?vDm#6OJPTUVd^ilHuLQ&|f_aEf&ky;1JPxwpzQ?*@w-He*fb9o3(22rbj+&cL>_P6*N4F_i(>||g~W(SjiJ3*eRmHgl2Dg53tZ>q_wvBi1(N?BGusJ+dO+rZmVv*29WG(ogWx+2YCPXi z=rs??7*uCAWV}!MW&ca`<1UAalKbZAA0t-xnHQVJ!TKR6sC3lK)-X@vxqx;u=Uv^Sls zuv?`I9Q4^T3ns#cX4_I{bj4WLa@YJ)Y_ZPA1BM$+(!{x&af504Oe!7_kphn{+?EVZ zbJ;|ZHvakgDWleAP2Nm*dSqfp#>Z_q;pv;g9eff1UV6$>f@yWn>wXu2=;`#8T*6Y_ zsygzgZhF+9+YB-d{T z6pr!=TEO^!4iWDr3TCM<@T?xrXOcx@rhD?IiEh7Zhd*_;N!yC~zBcU=*M8=4l;DmR z2P^`b5hrkSMTO7 z4oUJQkL)G@wa?oeu}A0)5JPz0~(E!l$~=HDgEHJYyL`+>MB>|2;zvPtv7J(HHYrmT?UU?6qgI!wDh6ZSPc zxE6=#*|5I_?WFB6v=*iIJpC`uc}pFD@O|@iG|sEZ;`J zaDki&i<3b|d3r;|ROGl~$IFY_FE6UG^~3*KA3fK#)OyM^ zcu#Na!y;X1RsN(ldHEqlDdN3Zbdo>Ay@as3yzTkW{rBs+_(K(Skt<^NZUqf>s;~e$ z8b&ZVUXfnb9e)PZJ#}bORO1n-$$_YFpL2ccO|k^@T&m1hw3yf0kuHQh5;iL0^(lJ& zER(==KEeRxxRdlYQU&sTXOTEldZ`B-S21rW{MCzk{+(!QEMzc3-rw(udae-ZQ@iB- z9eCm^DfFjuL$Q}Pr7-5Zt3lxe-@`Y!YeFnZj*<= z9Szv#WE#;-zt;;hS=z!b(i8?2Yz`B+#3s|q0h&1jr5oR;$kdVzWRO}t&CKH;4g@k! zf2+Q1eW3$ew85!3BK6|HSBAxD-Zdhg2=s%i=e%j@;2DM^$}GbHpm8AY5q#=zFN9cY zk6LFPj+0(|i)@+?(D}L^&UkXa5ZWXD+=CD9Nm*GG%+{AMueAQaG@kHoEmm*d3J%%W z*ko!{t=abdE}DVUOwukK{3e0^o-)5~Dq-xDmEKr6k_^0GDDnr6RCx)WBXwquF;9{= zPAKX(PSJL3p|LuHc*qBGj^IxZpA!$5Psq?Oi zeO0gNrGB3IvXK3}(!z>S!g7)cb3NX@6 z>S#cz^urmD;-o0!-(RAfGUG=8ReifN*&_qN25^3^OqVv)0|t3ZMceSeI;9FNmkf>e zhQZMNsK!^DDs1qN69*Y~*G$me7 zxsL;JhG_|J~t)9I;B?tFgv$T_eAedl-4x!ngdu5a5&@Y@HkGbxt#-UwPJv zT$un5T+BS%9Rmn{^`*zcGo9`4zyuj4OLpz|q(3zl#+eLG-IJU_rW1FUVQHzy8GW2G zH~X#)#*S?h2P4;8xeH}u-?F+RUCzxM#9uYl%3-QkMDNT0K@cq$F)IV;q$vH9V-bUJO7zx=ra7v?pr(0`CBHF3-ZuxD%?QhuD2YhF zPF#{X`lO`3KuiPqi#}K}!{OVh&11pqX`TqBWcbx19BoT^SeA7=LmG|xof?ULm94d` zJAQ9)YT0I!9Sq$>D^0m2%o(A0@tz*%0nAaMOgYI7!VokhG=(Vl;IwZFjzX1+-p^_D zwc9Gzz4O0_osoK8lvdZd0a5o)llVo`Md=-Xn+ zZ@56Nei^}&=2rh?fn>kkytwEzI7H3p&EKGWJZcZfkHk~jbiLmNfukR9W`_t-%^C@H zA{#tU&LSV=e;Rs`8~3_YH0K?y*nUa2_wUvjdP*;GCvd~xmnv>dnJ+k9=l_g1!peRd7!$>G z5IawqszWqYo{!L}wK02J(m(R|&g8q+9Zx&<7XwvR?#nW-v$)sAY_I-% z>{zI+#lS?T_)(S*J&c0y#dF1T(?71<7T#dJisJv3p^LkQ<#9F)$MJSWggnwklBW!H zsl%K_D}E!SyskBmPYanvB<>U@U_86*^5d_Ik(hLS(cL18lFgeTx?qLepKKeyC*XZ0 z7sn1B4~OYn@JeWI>Tv-6M{JeW284`y)~PrdZl+!=^A+;i5N{$#|$a~BIGG>z-#X49W5zMhD%>q!l`w1m?# z%l=u{Mt-|C|2iM2>!{8999mODgJ66KpANjjT(mxmuV;q2P5Vs>IAa#r{;>E%u(O&7 zAME>ufYHL50 zA(an9wdfbfY98SbTywSt`GZl-$%8nZHPVHL7L6tNF7t? zTD{rmc;46h&uFFxJKUPedxwHghjY{r16sEd_t?=PQWL=*c8xiqA+ikx(%7u!;cc%s zFLPvWr(_pg%4rw8b(Fk22U-lxp=h~K`H-41Q9Znrsi4=2j_lH@vpvDnA8MsOW?~f% zr2bji;_0Fe;-RA#=+H&r7NG$n|LvKYikBJFH4l^CobhTz*%8icXmZ?`Rv4yX%*_(j zbIox)Z}!&Py%`UJN<``Vloe7|QAD|`0U0ya|ISy6ogW~F1Q~`8YLxdD7Me={!CYUO zR=*$XsxX1hwcfLe`{(1s84uhH9&(Q2-vrB`#nRuAx6aJC0Nl+OQ>N6EzBGYpf}ASk zh5Tr|XUcb5uu(X{ijk|v=x5r;6&U8l5a~KA&wr)V)LUrmXDwc;dkL#W$)rMKL ztGbz2xQ%|83*t#LQkm10nCs>F1Vh07Jd$cCHU7O5gWGVR+V^yxGGjV_gasLQCJaP% z@=WN0DQ`%KLt5g}LY*Hu*(maY%8+^NzgMTz~ll?Vb7Nm9e5dVaSMvtNit% zV=k+eLDQg0<9o6>=>A=u3RptKDEH65INk?E4dPjMc_nRfY8hiSz=J9S1?QaJnE zI|n76UP0^J$t)fKrOU0(ePZabvWGFDHdvVCy`5M=v(2WeioN&uHaXSK24H`*HpPsfn^$_!OTVKgt9W|Vja@hN^ zghMbjr5!LV)E+2r1+&}?Y~)b2FP)b4F$@ppJu0#r#0@v1%SMm}vfU=zeJ@8%JL0sk z4S}7aNP7W0!^>Lv>*KDb{@kyZ@1S&4uaM;N^dM?NueZcgKm@J4!Y^Y!WdCmwR9AuS=OoFm zl`2};bpyc(OR%B*9?Q23;Mv6d({xY@D~Vomyw&>h$}^)8Qd#;n-0w|%*3k$GA zk0ed~>toAQqtl6n+8z+hA%CV{$kaO~DYaPYCb-$)Ptd^L|MXlif;i7Q%?uw$1aW~J z+X?xzpNVJc$sG^mi&8h>GrrRWZgx5!pud|@;dvHaGM}*9l44Fp*Ro`g^W{n1He5S1 z<*PJ%+(PB<^UDRal=MzLAU|QW`zVY&nNPd-qeE)==*UmH~BYQ+;2LuqD5m27h$a(1r`{AZ>-}WTsO6vyAKeYwDS(-g^ zUu<%4`&@9)BIt)xzZgFqyri7yGM%Lets%)D^UHD51d*!r zHSzYYrvUlw)F9fDp2<*Qn$YurFArcQ&c6I{bzY>E04*|uSYn=TDkQSPZdX-Fw{j%h zxY>+t$<=#pt}@mx!%@(8m*5u6kPqKNG(WJb-$iHt=d`C=zVAZA?*dMHQjthiUyYV9 z=9lK3!JDQpjk0x0n@U_CYkm&H2<@KvNKaTg3m5=hHx+4bl>{1mK};S?EXACbewH;lSNuLkr^jh67{c zgFFN%%uhe0=Qo%qzWWecMOo+pf`7vCf_z~{$|{0Q6*4VN(BjY3c#g+vP7pmtjL_wS4(JmI9%=CB;xW9=-M~TH*rwC^wL8$E^*d2dXqDY*oZK@N}beF;r0((~Rv z*pXXklI=ldlxR>_dX8dA(sRj0nn}?ghQea2oB?YSq(`(LrO_ISfwakqhc%xZLjK;` z_DlWVqx~XVKYzNs7(2V``E5NGoUX43m#64WN zyi;Z^ri)T9Z3$mFtFeR50C}o?fSoj?dy~ZZ;}Xo9C=R$M5t<$KS~w?dhn7V45PwLj z{s7F@S1f)oc|QxRtipM?pAGWU@epC^&?gr!VGw}1jhwzI*bKQpd>`k+Uzb7nCWb)L zyl*Tk;~TL{O#%1smPOAYy4*d65N#E`IC-(IXu#}qzMw8({?df5L)q+_yJV){c!*Rd z&U|Yr-eJRANjO~Z`Tc1_@)qF6By9o=S1PUA_ucA?2FbZKL7v%LweqKo0S)jS=hxRR z{d(i{6(InS;Q)n_P`f!PF4mnoaR>4T8N+`xmnh&=KQkIpx|UYe{;5msLyj`h>XDH# z(fvR|Jpijoo+nVNN$!nQ?dhJ@l%&0T;c?#Gt>0XR6Za#N4=3N>lWoe8#X4_flo^C6 zbxRvwE^#f>xe-0bsj>L-2;LUX%&QMO3p_QeBFH`Qo9MmErZ4`v!v(QW?r#<;rFW%wVx`{1niOa4({z%ghOm?v(` zI02>d1RQg4cFBJox`a-5XD7h*NL#}90$d7VYK|1Y%E)jPjgDb#L$J$oP>>l^F@K_} zQ9^xABI!kz#Tj(uROgA=2Md%&VREbvhMpOapW77|K!BJE-#bbj&^85tW4xS511Z_S zYeI2rRi2cOayv{6;ci=G+r^jJ#(>hK51P~|Ur&fo=71qBCN?k`X|)Q{P3_NoTAOj^ zQiVd7E~iPQdF2%IN;?RZB!6^;`_e)FG6`HO>ANsgpZVoh3Cvz=vAOgQ$)Fn^M_06p z#5kQtb_Z-dG*B~8a+5w6rMMW?dm7n0e|{Q@$>0Lp*0)Z0nxTW0!>JFvS|{GOzA(a- zc$sVLj&`W9Y7V?AiW%Z`j1-ok zG4ZfLNU0XBi@Gt$%YN(uVXYbH!tqk#BUf3X?YpvtZrLq=S4NW&P5RkXdppB-^e71S zyYiLh7DBs07#E)A#?%~F2&U?4T^weMn|-Hsr)x#VMenmy^XiSi7kaM$MP3-aTIiVh zHtC&1VvCBTyt$nJ-Oo{{&fdJAdcJE1-vv19@fY;@XJ_c=m-@c1iJcl#s37dtURB?c z*-(7_an?90eqZfUUybBo%3$)3S6wf@7(XJtv&?nfaxY?K5WY^U7jI^}LNh$hEq!01 z>XyiiBdxH6+P&zy;q?=2y9~VA`hK?3QRzJNI#|grQoq0J9}N@nOvD-gDRmF-S$dms zF5tw?r_KcmR3k$D@Xs8gvLI%?MU|QPn)j*C%J1ZSpYESOev}M{00pJw_^b4wC9sIg zU(G{QB3x&2mi_A3FnT7qT~`UK`*pf8BTZJz%Hxc*d(UNVXZk2ZgFZ{1?&~-^kk3*L z;^H=L9Ycfa-#^>D_l@Yln73H{wI)Dg)ZHE6Q6r9cZTFZy&v6^?UVLQ(4y=re{cr1O z`<^jW|DAc7Ud+%rI86DBO43d$Pc9=>ZNGq(^0#2>OrbL~L29u~Q!~XN z@zlA6@&B|)+PGe2Hm1rBr#3n>JkB0McrogKT}Zv4hE$>^-O>ATTWgl5jOWsY_m6XX z{J0AN#{`6`l@hO_oV_>7>oWfbtIet>>w&;Hf;(|dr^EeZ@Zh6ZT8kr@tu)v53N{s~ zQoPXG<4yqq>LfUb3hqH%3}8tl+b+6sJAYRyE$n)|00|Nljw9$=;pAz75^>r$*m>og zJfA!?cFf`}?u=bs=_vobfMl-MNe|a=8Ini4@#{chI>3L^e05Y1RH=#9SeU)PElD6m z{vANZPvYD%3(S8s>r#Rq4D`=)O|(nkFR0JCd|fh#^^R$M8O764faPc(k>&JKNxHP~ z#{125mp1phBsoL;IDLSW-?9XO42yVB92TDLekmIlmaT6ru6rKlsy07Ft8xg%nm;{Z z{^`DH{^(CY^{gw--K(6^y7i>);*=StLw~tz{P1iG#-}B%)VGA~_tW>!lY2@nFG)Tb zoN9R_1XuSdvx#zI>Tj*hC-sA?p2`k@pWUDfvIG+6RlZO6xJLofNzBN*Uo>ia%Hh$FHQaQtC&B^StW6rJGX<3@Bu7!5HOB4YNOhN6p4$ifq>+xSUTN~EE!n5<1 zm|1((=+}cc;>pbmX&#^4F=S{`B%Twf@Xjjo`%r8tHsuCP09doI zd{Nl^IxY>TpzdNYKXD{_*c9nHQS;#mj~i4@@^b z{d9X|@w^E%NW7`C2T-)TYtZ8f(S_)z>Kss>h2BtwMW@DAL1Wz(B_)Y0)zXQB?3>Aa z&XO{>*d>=yoU9&wO+vgVeY(3-mb$%mh|j1C*Qu`>_KjA4rI||kQmg9z0BL6-3o{7M z3a~xid*7)F24G!1sHVMKreuxb{j`mcMS9NSJ-BpC&T5_e{O0LSqt2w^W!;(w7ndhr zblyh1KOgo-Tj=y=6?2cZWr@hZeaRTA+D#hhMW<-G|C2z_(QY0gh&rFU|FCv9 z$8+|ZrPq9rjO2@7G7C8!T#>JK0CD)tQMiIEbs=hnaT&#tCZ>+e*w#1(a-!q$V%{``+OtpE#r(!Fv531|I# z>~)0?qVXV`TJ{hi0_}(|K>~l4_#pnb+@m3oNc^~%_7py3>jYdBdU4f7qc}TjvuOsd z##5;0ci06N{pdb`c-;NRRHkI#>X|x2?z#14(~+GQ{Yj_|U#iZ)w3r^n6{&qM7J44m zy0uas@X9xkjYmPtf@84pl$KDJwhM9q@l06xiK4#zUoqA18@lo_ z4k3GK7?FV8gN$50)9`V&d%5p9wY)v7sFZ!XcMX<1+kYeWBV0rue^IG_pg;UK$XSaj zE%mAu^U*CkiHTr%((#|Ebi0MFn5>j2|7g;%;N2!mlJw+4dMI?uaXd&=3|3eA+_n3O zV3_EL``XD*SAvi17g6c)@)P>k;1(GJ!dcmsFw1V?j{FQCVY#a_WHaKdnsGnhWZN^tPM*>5O;Sz@ zRr&JnedR%ry^IgK%h37x8Mt+4Ygwm=1t71z*5{|Uk%#c<_)}XN)-j4A=F{NkA<7DyN=hX< zv#*t}CV!RAUEavBy3v1Q-cR2r?Ls&M%Hjj}s8Qwg6|DoqG#j~k+znPd<}CwH6NpD? zbyw=63r8*${%A6}M{=5xdc&ew&$qZagk5JhwojR9oC)$n)6s347M#p}02rhUYr9KX zc-n;L38K)mZCmKoOI#=KgqN)PHfD`0^SBv?Xe-Wc`j}naCbe*1yhY_pRexxqho;&8 zC#|72_K9gw@EJuD{2 zu5D8z`Lc_)tIShicOf&ze&a>j@hex*lj+x2mtT72(mascv$)a27A0iC0T|$+F975p zP+~kcb-eHGpZ!C85_)e*tP=Wo>@RLg6*2%V4?KsoZL)uG+g=*G6d_SP(7^HB%(M{> zr58{OX#)V-5Uv*OUmnQfr|8TDE{jBud}uBHT|AL?ttEZK){a{$*o-{y`pZHSz7TC| z$*@arOp>Z)1=bZ`#2o~Kd>AN{CP=j17)qKIzX0=h9NVjYtuHhm^yq<;)_7Mr_=AhO zTq|jqFxz*w7^Ug>E$4Z*mdMlnt(mAL{(%AUBwIT1aC4sa8>&<*6AKC0AaS26kCe?p zd^C_5d*YK!^N|HTe3rIr%s-ksVE^XkUB#o2Pm`MQcj!=BH@$^wvO`XJy2qhoEfvrz znC_RX<9s#a?)dU}U1m87_3MF_eO|;RHOavs-%pjBN!{YAH(B~{Z>sOs`U;iTjvi7A zB9_wSBI;I&eVM-}a*xpK`$c|F-!=2F8$4G93oFc08QsS$j+{!{93Pom+o;$2wH`)C z^R9b@Wl*4*?P}y&{SiG`H$p^pRj}fgi<|b{faT=4uSgY>hOb7vx{ss%!?IsyeltL4 z15Uqt^lZx3fH)rpeClV9##BKm-7GMlh%DAf)Dq5mhX|3%JoUIY-m?*r|HYD5BOs2g)guLjS?S9biYJI&rCUan{wNZa2zji${)0%= zbG+7s%Tx?4V#(Njqt0(~KC{-I;a9pZ;e3Ku_Rnukv!z1KWQX%v=NdkJxk52G)8=1j z`>63fwqbjkaM&PiALJKr1*Da>0kw>>E)XyCPAzFvXdb5XS(`f}Bp9xDifS43;X#iR z5Tu4!akp}@G_33g4~EEy;*3&GwEcVRIBOCl+$jOSNx*=(he%5w9*Air^xRp?I5+oW z_&+3!!Vt7_ZT}H_nYVN&@0|kGq9SjBbWjt34Fih(lxL73{Ha+tj%vDMh+hjH-!W0= zGXKS#7I12xFe=f-UTA;$>MQ z>MQw&-lv(tDXjIZjA~`S8WS>}5owv}IKOCl3b!qi*q8XYRO%D|kcrRgl_Ry%=4;+n zCdkHDI-N%diJ#(O+hSX^2I_P#IN=bKt*mSQ{txH}h_7jw+ip_P{hxIi!kp)u_kbsL zQfcw|jg~A~@z6rmB`L0XmpHXWJkhXkUgE!`qm+GO*0W0EmWkR$%A1GZbdA2x7=%b^ z)v7e#I1rR^8!QUQ^ZD-O^*NalpZ=%1&^9;F4hx8kdJb1Z8`s-MzoL$_h(H$}?o9C* zh}R;lP)1s+{)mYHa~UYt&#&olD4O0CJP{&z$Fwcqucdz5JJ2jZYDPOcpK>|6q>3WG z^zX5OY(97h6lhWMR(=O=(vQeNJTl12xqHJa0Z!U6#+rWkHb5h>bT<3a`KO7mr2!&a z`t{|PF1eXfXnJ7ioY>ncJq^HK+^+TzC=4G1#>xLJN8@Sy_4r;S@Ee-MGsFaueJD>t zr}TqX$F|U`jj#L(TTNeT%0w#DwUIu6o87+lhP8?{-|}Oa0g5+myI( zZ6b2kc5e>6w-@rivfP;2xN@>zw8`@TJM^Y;McI&uoBdskD>j~N8~A7c!C}cTC5U7_ z-KCtu;-_1`U9*gO3-d6fVTM5g?X54@BHOfg1RW;ZmX|zDA4zEYQQ((eNLChLSkQw zLkrYTaw*3Aqc70!_3f_YcY{v9t2>)US=|K>3z@NwqO2vBVB?cuJEf)(etCx zIj)(rXLmp(6i@{rhFJpY=lCBmWm?iC6GVwxR6>=S*U(3u$0D3Hws>I;BEPkwwiVYP zklC4mkL}-F`(;}s3{LsC?83riEBBnzdb<@ZEL7U!v>E_aWNGcY9+%3Fui1YRNXoq^ zE@l8gE=N6g=h{KkGo8zwgEYlrq8}d>vQ9Ope6uR9{oVX!^i10t^C{4Jc~&1k3_6SO z73YV$K+grmUnb~Z!sd}CzHfQqa5;OBzxT?qW3|u2zTpF~$!47s+{{>|uso6Di+DcJ z;#+)^>_!qLg|w1{@D}|C4r-yVlHHtoh+deR(GVRq2Y5PXWYi>OStHuAT@aA)PuO28!WhJ+BY(VmdV{Gn_cZSZ4@zA_#Rp z0+osr-Ylq?gW(_)1TC{UC>5YU@%>;sQ(0rfE*K>o*JG{GIB?lt&1XB!!vY3G4C1Qc zCKLJse+Avu^SW=q3_ph~>cyIkUAfp7UGeiMZSyT*t6atU{^gleD{9}0h?lKy@)HUKC^lq|`zJ?n>S1IhUBFZu{Bz z&aso22e!uWU*%De;ivg*?T#Ds-T9x4v;QlIPkskM!l&u0#7{0@1k>OUZ!%<*6RtqF zMq9ph4vY!Bw?dV{_+JEC6yOiv99{>~8Z1KIVN%h>0QW)uzJyaK@97P1Ih-#)phD|J zOeI=4>h6<9pGXq$ixuJ*b%V%w&Yoh!I|P*Jq!Lr~!@SL^cuKA3s~V;i>W`y}d`*t| zpY2pIkG^f(vW}ns)kXboscA9{zObz#1%@_rYZ4xv=+z&jSV^h@saO=jPV}HDrh# zTT?{wmk3S(^!`H_y`e=F>5i8ikD$XP&mhTX#(X-*seCTUsOZa)@*(OmZtd2heNCBC zo^@NyjVGI#IAb9BLZ=@^x$_6^u_$U!wy8$16^2&El6t2ULYbhzpv!mGh^L2_%U3K< zzqB(_h+r#C6&?KDo_w>t`wsWa;kD?O8Wx4bg{dUVURQ}Em+T^zl*x;@L(#5nG^Cbn)azv&e&l z^_&CG(Nzq8rtafAzaD`tW5hH)`tSZ()Gvo{oy$)o2};*Q7a!!K_#$wm5GXx@1Qkbg znJrPzC!m80D^o*j>XgFGx726gHv=zQAoZnM276#fCMIxGGSn-g?TZ(be)}d24%=dF1M^{^^^K zWOT7Ya)$%=T!5n6&36y9ldDTtr>3G}+J_#+=>pYTH>;c)d8zfcmo6Kn2`QNWIQHjs zczgfFHd;_n$Wd_I8QI=9T>dT*s%z$7FFud<Vf_z*sv*M zSh~{B_T{_$)EE1N>emnHCGys0n2OFd!Vq8EAh0R8OZ1QJ=|~82XXnuxeVMfv)gebB)`U83-OH7oLcU&d1=(Bo^bItgL`wF{F`?qZ-XeGk5yI8 zR+fOopB-6dIw$u6{Kd+IL=oFb=?H%r;uzd@Y9XM!HhUl-UYzw^oGTnf)FLnRWN*Bp z;!@kAD~JL6k5@IPwZv!G!h2O+68x84U5vTR3C(xDM>*-_5)?55X zQf<*;dUF%rLwefUF@GCum`5n*|JSixi7|%uLT2do#7&@Vx8mY=D3hS6a z^&sB~lY*+Q3iGPYVz2zF)4#CW;E2mWMuPtC_UyMD6&t!ZRci^_k2=P@LLK=Ss_Tp+ zJ9Y#`Rm}zD52Z>u|&x**jHS-y{OfO{_D&oB)rzXuYSaqj`h zs`U?Bz5I8lqBO^o?tc}Nb0`bGampv6I{p#`m(1jUO9XOZ4kNI6kgm$ee*8o~2_{DHYUH?A9Hv#N49wO29q__S)J4WFJs`H4H3G(_- z&e?VC+CQ>E%6+fzH*dev<$L(*(~A#FLVf1t;bnIA1Tbd!D>D~ZKXVt3wc9cxU6|3| zo6zZnowyp9*OEhXeY;=`f9-qu;CaymJInl%`EdacW!qd!b)9GMWX%O-Nz_pe3&fiC z;A7g>E(|`Zg5_Dc>GYjs$+%c)#}|ZDt+ux3#~62_;<(zygLW3Pz?hR#C6LPb=gbtE z%%^reAnTcKXyJCY8<0*Ty|NXmq5dZfF65F;3jNrH9Ok65#%~UM`;|9XE9QAgc2lWX z@BrbcL+zAU{A*V=j2yRr>B%2pEIl+D?QHA-d3?eMDFbxkn8)2u~x%Rsh>^TJG1jhDAn~VQZd&m{!|<@+Jz?O*#U+^DOe8)`|875 zJm2&l{-pDoHVjvgYZhEt-!5F7KPllIDBTG889XwP7v?>L2D~*)x1$PmFtK!&G@4Qq zful^0riPz^6vgq^sqc+dG_+Y8=^f7M1{iP6WNf}P%lOg@$&Amob+a3eE-kyJ$d5mT zH(ae3lRfh0Hs#9bzHL#gZ(O+_Xl&`fC2cOX(TFpG^Dv^AF7zZyD}gkOe6rW!WRTrk zBCG9Jrk_$_T)oB>bW{-c2s=Lx32IoEO4dA(kUdF|==7uGsVS^;9F@!~^q|^+!^qkS zdY&x>GyIz=te+xQ8mennL0AXKv0%O)o2FthzmhxO4?@pH*RP|ets&h&dzWh&1z4&g zFuxA12Z-xF%}G+RflOS+aMEjDz7p5xMTE2GO>YN1uE^I{U*i8#+yVNs_=Uc7`dJUD zN|#GJw=zohTHlUGu{3tj9EwPK{9%)&4A?VBZvowx5%bn}jSjHgxbD?TdXR;%l_t#* z?^|R$aghp@O5cukiuS>%7~MW|jU!v-#x2k}rZ&aDw^)l7dGt~V>P0PXnrE91*}x1T z*1@|&Ao|OB7q1}i%7W8x>~1s)im3cD^9gjZMzS*^jIGqf-1wZpgXjt8s8nUHvo=C7 zmoZ0k<>Z&EpvG#XPF^T$KRjL=?oS!$+i)TwQX|a6=b)CXKKhvN$9}lpyYBs+K!JUM zmX53$1;3yLWf>39*PNfdTIxB;hU+f)-fZF&Xf^~cUSU|Rl`vB6&l!oIVLwVgBs(dlfgz zBC}#)V~IN|PHu@>HxW$%rOiQ;_DGEyxMRd?`#vw})Jp5yNBl3Hd~z+OJmN_}m?S_kf!r#S!}+BZh>wpqRxg~esU{b2Z(lSi~2LFv|&YdYYejzV3SE?wobUyLMVmd{fXvjJz z$KYjsVM$^s&<%Dfuhn;rwkBs@E3uH@l+4cc>>&TkgaMKJ8lQ>IG<~1bmuP0{u5G^Z|)$~escbs^aJ~$1r*tmej zBG0DiW?r`j_Xt~kGRX^wId$h1uDQik4&{P>^292!{Th{M+_-!eF5s42?2?xoS{Rw` z*p8Y>Z;jI;7>BN^FR$0ccfi%?#gzRe<)`X+fN*v$q6mBe7VDDe>ij@JwdT9^C8>*b z9X%(qxUUA;)#)}{pOIZ4J4+bBruTTKVxC_fOr)Z_M8?~ac(M|G0sf$H6*y%mf z5}}K;DI&tl@(-Ws7VMiOTzN{{&%lR@&mcb|Ffuue%dnn4ftaYQh~b+NBEUl}P4n6D zzW|v%v3aR3^!#!%-))PlvX`=>lst3qW=VuosE^Z^8?kG8;p?@ZNDj`fhvVtYHag}} zG{ZiAu2X<6>;JJM`)McVPa*)d;^69fveqe=3&I2t!C$|OPyf-t z_o0)VP&{?|=V$0e7RbJ-{L;e^F6H|n7V?hQPi+N=kK9Z1+<7@5SrU}ZoB#EV=OiYw zwqcbX^sC0DP-jg}OUWqulH`@B&wrV#LErvQAm9Jwj{SeX_kRaad_~a}tkG%8c%QlJ z{R>7D?S>>RDmvMtAM0Y=ghb}T{J2cy_Ku6T-^{%QZ=N!`6@Pn1MD3n1cOvT=qRU)g zGN43{K1hnQ{Je0YT@-P?suboANm*}2rAO%4w-Ol+jlmdfalpUF+OZADl@eofzgwwk z9(7Gl-#vF$EIB`Pa}>CbZ^X*_Vso}dkI#&+Nw`+JR zIEm>wd57BNYq--_ds$$U~$w$e1=1PeXkI?%at2YCm7iO}TDwE*C z3mB)3tCW)c9>)bPzZUA={Bzk9hiRsD-Kt{)gdSzt6y)DpA}VRz#M2j=H?#tNTpH*t zt^#Tn$3%Sw3CE9>sM0+a4}^chL1VfmCDK$YJJ1$X#0(5FZ{*?kg%#b|u0%x%;nP_N?Z;kOBn*DQ<{aMqcjvnDR2f0^Dk zfZ&f8VS=c@)zpy0js*g>MgOOw5IRB3g4UP9d1qP3<46xXo7?Sv&IKctDXypcD0z*<6XXHP1{Wuko^*8XlD-fJ*#ApTCcdQj`|+qzuKywS9Y*uhDn~@u-E9F zx40gir?2&eaqH)Bmie!#BzOs;XjC*kUnVXla(hhq%b#* " +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/.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 @@ + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml index a55e7a1..0f7bc51 100644 --- a/.idea/codeStyles/codeStyleConfig.xml +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -1,5 +1,5 @@ - - \ 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 @@ + + + + + + + + + + diff --git a/.idea/live-templates/User.xml b/.idea/live-templates/User.xml new file mode 100644 index 0000000..cc15650 --- /dev/null +++ b/.idea/live-templates/User.xml @@ -0,0 +1,11 @@ + + + diff --git a/.idea/misc.xml b/.idea/misc.xml index 039e6a7..5358548 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,43 @@ - - - \ 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/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

From a58eb54bf120f624ea82af68a3a8be47422ebde9 Mon Sep 17 00:00:00 2001
From: alexander-yevsyukov 
Date: Mon, 25 May 2026 16:50:44 +0100
Subject: [PATCH 3/7] Exclude dirs and files needed for `check-links` skill

---
 .gitignore | 4 ++++
 1 file changed, 4 insertions(+)

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

From 648d902860d442b0683357c84c4966bb791936fa Mon Sep 17 00:00:00 2001
From: alexander-yevsyukov 
Date: Mon, 25 May 2026 16:51:07 +0100
Subject: [PATCH 4/7] Update Validation docs for the preview site

---
 docs/_preview/go.mod | 2 +-
 docs/_preview/go.sum | 2 ++
 2 files changed, 3 insertions(+), 1 deletion(-)

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=

From 5c24f724bbfc4edb8778e168ea0b54b83448c096 Mon Sep 17 00:00:00 2001
From: alexander-yevsyukov 
Date: Mon, 25 May 2026 16:51:25 +0100
Subject: [PATCH 5/7] Describe this project in `project.md`

---
 .agents/project.md | 63 ++++++++++++++++++++++++++++++++++++++--------
 1 file changed, 52 insertions(+), 11 deletions(-)

diff --git a/.agents/project.md b/.agents/project.md
index b6882e0..77c3f9c 100644
--- a/.agents/project.md
+++ b/.agents/project.md
@@ -1,18 +1,59 @@
-
-
-# Project: 
+# Project: documentation
 
 ## Overview
 
-*One paragraph: what this repo is, what problem it solves, and its role in the
-Spine SDK organisation.*
+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: library / tool / Gradle plugin / application.
-Key patterns, public API boundaries, and constraints specific to this repo.*
+**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

From b1aeb0caf34cb25fe3f22bd5150ed2b2518fa8a4 Mon Sep 17 00:00:00 2001
From: alexander-yevsyukov 
Date: Mon, 25 May 2026 16:53:52 +0100
Subject: [PATCH 6/7] Add hugo module to the main docs module

---
 docs/go.mod | 2 ++
 docs/go.sum | 2 ++
 2 files changed, 4 insertions(+)
 create mode 100644 docs/go.sum

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=

From cdc9504b3ef784bf06685dd00dd5da690ad0c63c Mon Sep 17 00:00:00 2001
From: alexander-yevsyukov 
Date: Mon, 25 May 2026 16:54:12 +0100
Subject: [PATCH 7/7] Auto-update by IDEA

---
 .idea/codeStyles/codeStyleConfig.xml | 3 ++-
 .idea/misc.xml                       | 3 ++-
 2 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
index 0f7bc51..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/misc.xml b/.idea/misc.xml
index 5358548..ad582f4 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,3 +1,4 @@
+
 
   
     
@@ -39,5 +40,5 @@
       
     
   
-  
+  
 
\ No newline at end of file