From 6f78557c016b8a3fb2d8636afda178dbe4e00595 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Mon, 25 May 2026 17:13:53 +0100 Subject: [PATCH 01/35] Add config as a Git submodule Co-Authored-By: Claude Haiku 4.5 --- .gitmodules | 3 +++ config | 1 + 2 files changed, 4 insertions(+) create mode 160000 config diff --git a/.gitmodules b/.gitmodules index 81c04cfe6..429969fde 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,3 +13,6 @@ [submodule "_code/examples/todo-list"] path = _code/examples/todo-list url = https://github.com/spine-examples/todo-list +[submodule "config"] + path = config + url = https://github.com/SpineEventEngine/config diff --git a/config b/config new file mode 160000 index 000000000..56b5c9070 --- /dev/null +++ b/config @@ -0,0 +1 @@ +Subproject commit 56b5c9070ad0efcadc3a96256e4c4937b0528e4e From 42190b6ff4182392048f6b1e0fca7c7ed4d1ee4d Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Mon, 25 May 2026 17:24:49 +0100 Subject: [PATCH 02/35] Rollback Gradle to 6.7.1 Co-Authored-By: Claude Haiku 4.5 --- .agents/memory/feedback/.gitkeep | 0 .agents/memory/project/.gitkeep | 0 .agents/memory/reference/.gitkeep | 0 .agents/scripts/api-discovery/.gitignore | 3 ++ .codecov.yml | 17 ---------- buildSrc/build.gradle.kts | 40 ------------------------ gradle/wrapper/gradle-wrapper.properties | 6 +++- 7 files changed, 8 insertions(+), 58 deletions(-) create mode 100644 .agents/memory/feedback/.gitkeep create mode 100644 .agents/memory/project/.gitkeep create mode 100644 .agents/memory/reference/.gitkeep create mode 100644 .agents/scripts/api-discovery/.gitignore delete mode 100644 .codecov.yml delete mode 100644 buildSrc/build.gradle.kts diff --git a/.agents/memory/feedback/.gitkeep b/.agents/memory/feedback/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/.agents/memory/project/.gitkeep b/.agents/memory/project/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/.agents/memory/reference/.gitkeep b/.agents/memory/reference/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/.agents/scripts/api-discovery/.gitignore b/.agents/scripts/api-discovery/.gitignore new file mode 100644 index 000000000..c824ff1d5 --- /dev/null +++ b/.agents/scripts/api-discovery/.gitignore @@ -0,0 +1,3 @@ +# Per-developer override of for the api-discovery +# extraction cache. Contains an absolute path; do not commit. +.workspace-root diff --git a/.codecov.yml b/.codecov.yml deleted file mode 100644 index 6166576eb..000000000 --- a/.codecov.yml +++ /dev/null @@ -1,17 +0,0 @@ -# -# See default configuration here: https://github.com/codecov/support/blob/master/codecov.yml -# -# For more options see: https://gist.github.com/stevepeak/53bee7b2c326b24a9b4a -# -# Codecov documentation is available here: https://codecov.io/docs - -coverage: - ignore: - - generated/* - - examples/* - - test/* - status: - patch: false - -comment: - layout: "header, diff, changes, uncovered" diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts deleted file mode 100644 index 5de0ba65e..000000000 --- a/buildSrc/build.gradle.kts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2020, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -plugins { - `kotlin-dsl` -} - -repositories { - mavenLocal() - mavenCentral() -} - -val jacksonVersion = "2.11.0" - -dependencies { - implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:$jacksonVersion") -} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9937dae91..340b25329 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 6e96cf0a737ca4d93095cb4518adaf4069665e69 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Mon, 25 May 2026 17:26:48 +0100 Subject: [PATCH 03/35] Apply `config` --- .agents/_TOC.md | 24 ++ .agents/advanced-safety-rules.md | 6 + .agents/coding-guidelines.md | 39 ++ .agents/common-tasks.md | 6 + .agents/documentation-guidelines.md | 14 + .agents/documentation-tasks.md | 20 + .agents/jvm-project.md | 37 ++ .agents/memory/MEMORY.md | 17 + .agents/memory/README.md | 89 ++++ .../memory/feedback/copilot-review-request.md | 35 ++ .../memory/reference/anthropic-api-caching.md | 52 +++ .agents/memory/reference/cache-warm-window.md | 33 ++ .agents/project-overview.md | 7 + .agents/project-structure-expectations.md | 21 + .agents/project.md | 18 + .agents/project.template.md | 18 + .agents/quick-reference-card.md | 7 + .agents/refactoring-guidelines.md | 3 + .agents/running-builds.md | 18 + .agents/safety-rules.md | 49 +++ .agents/scripts/api-discovery/README.md | 158 +++++++ .agents/scripts/api-discovery/clean-cache | 143 +++++++ .agents/scripts/api-discovery/discover | 153 +++++++ .agents/scripts/api-discovery/extract-sources | 118 ++++++ .agents/scripts/api-discovery/lib/common.sh | 364 ++++++++++++++++ .agents/scripts/api-discovery/update-sibling | 159 +++++++ .agents/scripts/pre-pr-gate.sh | 73 ++++ .agents/scripts/protect-version-file.sh | 46 +++ .agents/scripts/publish-version-gate.sh | 93 +++++ .agents/scripts/sanitize-source-code.sh | 47 +++ .agents/scripts/update-copyright.sh | 48 +++ .agents/skills/api-discovery/SKILL.md | 288 +++++++++++++ .agents/skills/bump-gradle/SKILL.md | 148 +++++++ .agents/skills/bump-gradle/agents/openai.yaml | 4 + .agents/skills/bump-version/SKILL.md | 135 ++++++ .../skills/bump-version/agents/openai.yaml | 4 + .agents/skills/check-links/SKILL.md | 320 ++++++++++++++ .agents/skills/dependency-audit/SKILL.md | 146 +++++++ .agents/skills/dependency-update/SKILL.md | 283 +++++++++++++ .../dependency-update/agents/openai.yaml | 4 + .agents/skills/java-to-kotlin/SKILL.md | 59 +++ .../skills/java-to-kotlin/agents/openai.yaml | 4 + .agents/skills/kotlin-review/SKILL.md | 62 +++ .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/api-discovery.md | 156 +++++++ .agents/tasks/prohibit-automatic-commits.md | 92 +++++ .agents/tasks/prompt-caching-org.md | 165 ++++++++ .../setup-cross-tool-agent-instructions.md | 138 +++++++ .agents/testing.md | 8 + .agents/version-policy.md | 19 + .agents/widow-runt-orphan.jpg | Bin 0 -> 54071 bytes .claude/agents/dependency-audit.md | 19 + .claude/agents/kotlin-review.md | 17 + .claude/agents/review-docs.md | 18 + .claude/commands/bump-gradle.md | 13 + .claude/commands/bump-version.md | 13 + .claude/commands/dependency-update.md | 16 + .claude/commands/java-to-kotlin.md | 13 + .claude/commands/move-files.md | 12 + .claude/commands/pre-pr.md | 32 ++ .claude/commands/review-docs.md | 21 + .claude/commands/run-build.md | 12 + .claude/commands/update-copyright.md | 12 + .claude/commands/write-docs.md | 14 + .claude/settings.json | 96 +++++ .claude/skills | 1 + .github/copilot-instructions.md | 26 ++ .github/workflows/build-on-ubuntu.yml | 38 ++ .github/workflows/build-on-windows.yml | 36 ++ .github/workflows/check-links.yml | 205 +++++++++ .github/workflows/ensure-reports-updated.yml | 25 ++ .../workflows/gradle-wrapper-validation.yml | 19 + .github/workflows/increment-guard.yml | 29 ++ .github/workflows/publish.yml | 65 +++ ...move-obsolete-artifacts-from-packages.yaml | 73 ++++ .gitignore | 154 +++++-- .idea/codeStyles/Project.xml | 27 +- .idea/copyright/TeamDev_Open_Source.xml | 2 +- .idea/dictionaries/common.xml | 8 +- .idea/inspectionProfiles/Project_Default.xml | 144 +++++-- .idea/live-templates/README.md | 27 ++ .idea/live-templates/Spine.xml | 58 +++ .idea/live-templates/User.xml | 11 + .idea/misc.xml | 58 ++- .junie/guidelines.md | 21 + .junie/skills | 1 + AGENTS.md | 79 ++++ CLAUDE.md | 123 +----- CODE_OF_CONDUCT.md | 128 ++++++ gradle.properties | 25 ++ gradle/wrapper/gradle-wrapper.jar | Bin 59203 -> 48462 bytes gradlew | 283 ++++++++----- gradlew.bat | 59 ++- 108 files changed, 7164 insertions(+), 369 deletions(-) create mode 100644 .agents/_TOC.md create mode 100644 .agents/advanced-safety-rules.md create mode 100644 .agents/coding-guidelines.md create mode 100644 .agents/common-tasks.md create mode 100644 .agents/documentation-guidelines.md create mode 100644 .agents/documentation-tasks.md create mode 100644 .agents/jvm-project.md create mode 100644 .agents/memory/MEMORY.md create mode 100644 .agents/memory/README.md create mode 100644 .agents/memory/feedback/copilot-review-request.md 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-overview.md create mode 100644 .agents/project-structure-expectations.md create mode 100644 .agents/project.md create mode 100644 .agents/project.template.md create mode 100644 .agents/quick-reference-card.md create mode 100644 .agents/refactoring-guidelines.md create mode 100644 .agents/running-builds.md create mode 100644 .agents/safety-rules.md create mode 100644 .agents/scripts/api-discovery/README.md create mode 100755 .agents/scripts/api-discovery/clean-cache create mode 100755 .agents/scripts/api-discovery/discover create mode 100755 .agents/scripts/api-discovery/extract-sources create mode 100644 .agents/scripts/api-discovery/lib/common.sh create mode 100755 .agents/scripts/api-discovery/update-sibling create mode 100755 .agents/scripts/pre-pr-gate.sh create mode 100755 .agents/scripts/protect-version-file.sh create mode 100755 .agents/scripts/publish-version-gate.sh create mode 100755 .agents/scripts/sanitize-source-code.sh create mode 100755 .agents/scripts/update-copyright.sh create mode 100644 .agents/skills/api-discovery/SKILL.md create mode 100644 .agents/skills/bump-gradle/SKILL.md create mode 100644 .agents/skills/bump-gradle/agents/openai.yaml create mode 100644 .agents/skills/bump-version/SKILL.md create mode 100644 .agents/skills/bump-version/agents/openai.yaml create mode 100644 .agents/skills/check-links/SKILL.md create mode 100644 .agents/skills/dependency-audit/SKILL.md create mode 100644 .agents/skills/dependency-update/SKILL.md create mode 100644 .agents/skills/dependency-update/agents/openai.yaml create mode 100644 .agents/skills/java-to-kotlin/SKILL.md create mode 100644 .agents/skills/java-to-kotlin/agents/openai.yaml create mode 100644 .agents/skills/kotlin-review/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/api-discovery.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/testing.md create mode 100644 .agents/version-policy.md create mode 100644 .agents/widow-runt-orphan.jpg create mode 100644 .claude/agents/dependency-audit.md create mode 100644 .claude/agents/kotlin-review.md create mode 100644 .claude/agents/review-docs.md create mode 100644 .claude/commands/bump-gradle.md create mode 100644 .claude/commands/bump-version.md create mode 100644 .claude/commands/dependency-update.md create mode 100644 .claude/commands/java-to-kotlin.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/run-build.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 .github/copilot-instructions.md create mode 100644 .github/workflows/build-on-ubuntu.yml create mode 100644 .github/workflows/build-on-windows.yml create mode 100644 .github/workflows/check-links.yml create mode 100644 .github/workflows/ensure-reports-updated.yml create mode 100644 .github/workflows/gradle-wrapper-validation.yml create mode 100644 .github/workflows/increment-guard.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/remove-obsolete-artifacts-from-packages.yaml 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 gradle.properties diff --git a/.agents/_TOC.md b/.agents/_TOC.md new file mode 100644 index 000000000..cb993b725 --- /dev/null +++ b/.agents/_TOC.md @@ -0,0 +1,24 @@ +# Table of Contents + +1. [Quick Reference Card](quick-reference-card.md) +2. [Project overview](project-overview.md) +3. [JVM project requirements](jvm-project.md) β€” language, build, and review checklist shared by all JVM repos +4. [Coding guidelines](coding-guidelines.md) +5. [Documentation & comments](documentation-guidelines.md) +6. [Documentation tasks](documentation-tasks.md) +7. [Running builds](running-builds.md) +8. [Version policy](version-policy.md) +9. [Project structure expectations](project-structure-expectations.md) +10. [Testing](testing.md) +11. [Safety rules](safety-rules.md) +12. [Advanced safety rules](advanced-safety-rules.md) +13. [Refactoring guidelines](refactoring-guidelines.md) +14. [Common tasks](common-tasks.md) +15. [Team memory](memory/MEMORY.md) +16. [Task plans](tasks/README.md) +17. [Java to Kotlin conversion](skills/java-to-kotlin/SKILL.md) +18. [Dependency update](skills/dependency-update/SKILL.md) +19. [Documentation review](skills/review-docs/SKILL.md) +20. [Pre-PR checklist](skills/pre-pr/SKILL.md) +21. [Kotlin code review](skills/kotlin-review/SKILL.md) +22. [Dependency audit](skills/dependency-audit/SKILL.md) diff --git a/.agents/advanced-safety-rules.md b/.agents/advanced-safety-rules.md new file mode 100644 index 000000000..e4105813f --- /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/coding-guidelines.md b/.agents/coding-guidelines.md new file mode 100644 index 000000000..12ede97cd --- /dev/null +++ b/.agents/coding-guidelines.md @@ -0,0 +1,39 @@ +# 🧾 Coding guidelines + +## Core principles + +- Adhere to [Spine Event Engine Documentation][spine-docs] for coding style. +- Generate code that compiles cleanly and passes static analysis. +- Respect existing architecture, naming conventions, and project structure. +- Write clear, incremental commits with descriptive messages. +- Include automated tests for any code change that alters functionality. + +## Kotlin best practices + +### βœ… Prefer +- **Kotlin idioms** over Java-style approaches: + - Extension functions + - `when` expressions + - Smart casts + - Data classes and sealed classes + - Immutable data structures +- **Simple nouns** over composite nouns (`user` > `userAccount`) +- **Generic parameters** over explicit variable types (`val list = mutableList()`) +- **Java interop annotations** only when needed (`@file:JvmName`, `@JvmStatic`) +- **Kotlin DSL** for Gradle files + +### ❌ Avoid +- Mutable data structures +- Java-style verbosity (builders with setters) +- Redundant null checks (`?.let` misuse) +- Using `!!` unless clearly justified +- Type names in variable names (`userObject`, `itemList`) +- String duplication (use constants in companion objects) +- Mixing Groovy and Kotlin DSLs in build logic +- Reflection unless specifically requested + +## Text formatting + - βœ… Replace double empty lines with a single empty line in the code. + - βœ… Remove trailing space characters in the code. + +[spine-docs]: https://github.com/SpineEventEngine/documentation/wiki diff --git a/.agents/common-tasks.md b/.agents/common-tasks.md new file mode 100644 index 000000000..5ee954d83 --- /dev/null +++ b/.agents/common-tasks.md @@ -0,0 +1,6 @@ +# πŸ“‹ Common tasks + +- **Adding a new dependency**: Update relevant files in `buildSrc` directory. +- **Creating a new module**: Follow existing module structure patterns. +- **Documentation**: Use KDoc style for public and internal APIs. +- **Testing**: Create comprehensive tests using Kotest assertions. diff --git a/.agents/documentation-guidelines.md b/.agents/documentation-guidelines.md new file mode 100644 index 000000000..6c9c1bae7 --- /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 000000000..8ac4660db --- /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/jvm-project.md b/.agents/jvm-project.md new file mode 100644 index 000000000..e3c5d650d --- /dev/null +++ b/.agents/jvm-project.md @@ -0,0 +1,37 @@ +# JVM Project Requirements + +General requirements for all JVM projects in the Spine SDK organisation. +Repo-specific `project.md` files link here and add their own context. + +## Language and build + +- **Languages**: Kotlin (primary), Java (secondary). +- **Build**: Gradle with Kotlin DSL. +- **Static analysis**: detekt, ErrorProne, Checkstyle, PMD. +- **Testing**: JUnit 5, Kotest Assertions, Codecov. + +## Code review checklist + +**Correctness and safety** +- Code compiles and passes static analysis (detekt, ErrorProne, Checkstyle, PMD). +- No reflection or unsafe code unless explicitly approved in scope. +- No analytics, telemetry, or tracking code. +- No blocking calls inside coroutines. + +**Kotlin/Java style** +- Kotlin idioms preferred: extension functions, `when` expressions, data/sealed + classes, immutable data structures. +- No `!!` unless provably safe. No unchecked casts. +- No mutable state without justification. +- No string duplication β€” use constants. + +**Tests** +- New or changed functionality must include tests. +- Use stubs, not mocks. +- Prefer [Kotest assertions][kotest-assertions] over JUnit or Google Truth. + +**Versioning** +- If the repo has `version.gradle.kts`, every PR must include a version bump. + Flag the absence as a required change. + +[kotest-assertions]: https://kotest.io/docs/assertions/assertions.html diff --git a/.agents/memory/MEMORY.md b/.agents/memory/MEMORY.md new file mode 100644 index 000000000..2c8045c6e --- /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 000000000..899d9e558 --- /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/copilot-review-request.md b/.agents/memory/feedback/copilot-review-request.md new file mode 100644 index 000000000..f5dde9b46 --- /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/reference/anthropic-api-caching.md b/.agents/memory/reference/anthropic-api-caching.md new file mode 100644 index 000000000..bcb1be4cc --- /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 000000000..796dd4d30 --- /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-overview.md b/.agents/project-overview.md new file mode 100644 index 000000000..dfac73f03 --- /dev/null +++ b/.agents/project-overview.md @@ -0,0 +1,7 @@ +# πŸ› οΈ Project overview + +- **Languages**: Kotlin (primary), Java (secondary). +- **Build tool**: Gradle with Kotlin DSL. +- **Static analysis**: detekt, ErrorProne, Checkstyle, PMD. +- **Testing**: JUnit 5, Kotest Assertions, Codecov. +- **Tools used**: Gradle plugins, IntelliJ IDEA Platform, KSP, KotlinPoet, Dokka. diff --git a/.agents/project-structure-expectations.md b/.agents/project-structure-expectations.md new file mode 100644 index 000000000..22a3ab7d6 --- /dev/null +++ b/.agents/project-structure-expectations.md @@ -0,0 +1,21 @@ +# πŸ“ Project structure expectations + +```yaml +.github +buildSrc/ + + src/ + β”œβ”€β”€ main/ + β”‚ β”œβ”€β”€ kotlin/ # Kotlin source files + β”‚ └── java/ # Legacy Java code + β”œβ”€β”€ test/ + β”‚ └── kotlin/ # Unit and integration tests + build.gradle.kts # Kotlin-based build configuration + + +build.gradle.kts # Kotlin-based build configuration +settings.gradle.kts # Project structure and settings +README.md # Project overview +AGENTS.md # Entry point for LLM agent instructions +version.gradle.kts # Declares the project version in versioned Gradle Build Tools repos. +``` diff --git a/.agents/project.md b/.agents/project.md new file mode 100644 index 000000000..b6882e03a --- /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 000000000..b6882e03a --- /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/quick-reference-card.md b/.agents/quick-reference-card.md new file mode 100644 index 000000000..2e890e428 --- /dev/null +++ b/.agents/quick-reference-card.md @@ -0,0 +1,7 @@ +# πŸ“ Quick Reference Card + +🚫 **Do not write to git history** (commit/push/tag/rebase/merge/cherry-pick/reset/`gh pr merge`) without explicit authorization. See +[`safety-rules.md`](safety-rules.md) β†’ *Commits and history-writing*. +Authorization comes only from a skill's `## Commit authorization` +section or from the user's current prompt β€” never from prior turns or +memory. diff --git a/.agents/refactoring-guidelines.md b/.agents/refactoring-guidelines.md new file mode 100644 index 000000000..191db49f5 --- /dev/null +++ b/.agents/refactoring-guidelines.md @@ -0,0 +1,3 @@ +# βš™οΈ Refactoring guidelines + +- Do NOT replace Kotest assertions with standard Kotlin's built-in test assertions. diff --git a/.agents/running-builds.md b/.agents/running-builds.md new file mode 100644 index 000000000..db0338d6f --- /dev/null +++ b/.agents/running-builds.md @@ -0,0 +1,18 @@ +# Running builds + +1. When modifying code, run: + ```bash + ./gradlew build + ``` + +2. If Protobuf (`.proto`) files are modified run: + ```bash + ./gradlew clean build + ``` + +3. Documentation-only changes in Kotlin or Java sources run: + ```bash + ./gradlew dokka + ``` + +4. Documentation-only changes do not require running tests! diff --git a/.agents/safety-rules.md b/.agents/safety-rules.md new file mode 100644 index 000000000..e7fece3cc --- /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/api-discovery/README.md b/.agents/scripts/api-discovery/README.md new file mode 100644 index 000000000..de4c631a1 --- /dev/null +++ b/.agents/scripts/api-discovery/README.md @@ -0,0 +1,158 @@ +# `api-discovery` scripts + +Resolve the on-disk location of a Maven artifact's source code for +agents and developers, without repeatedly `unzip`-ing JARs out of the +Gradle cache. + +The agent-facing documentation lives in +[`../../skills/api-discovery/SKILL.md`](../../skills/api-discovery/SKILL.md); +this file is the implementation reference. + +## Why + +Agents investigating library APIs used to run dozens of `find +~/.gradle/caches` + `unzip -l` + `unzip -p` calls per question. Each +`unzip` decompresses the archive from scratch β€” slow and token-heavy. + +This package replaces that pattern with two cheap reads: + +1. **Sibling-first** β€” every Spine artifact maps to a sibling clone + under `//`. The source tree is already on + disk; we just resolve the right submodule path. +2. **Extraction cache** β€” non-Spine artifacts (Jackson, Guava, etc.) + have their `-sources.jar` extracted **once** to a per-workstation + cache. Subsequent queries return instantly. + +## Layout + +``` +.agents/scripts/api-discovery/ +β”œβ”€β”€ README.md # this file +β”œβ”€β”€ lib/common.sh # shared bash helpers +β”œβ”€β”€ discover # main entry β€” resolve a coordinate to a path +β”œβ”€β”€ extract-sources # one-shot JAR extraction (race-safe) +β”œβ”€β”€ update-sibling # `git pull --ff-only` a stale sibling (safe-guarded) +└── clean-cache # prune the extraction cache +``` + +The cache itself is **not** under the repo. It lives at: + +``` +/.agents/caches/api-discovery/sources//// +``` + +`` defaults to the parent of the consumer repo (e.g. +`/Users//Projects/Spine/` when the consumer repo is +`.../Spine/config/`). To override, write the absolute path to an +alternative root into `.workspace-root` next to this README (the +script is gitignored). + +## Bootstrap + +First-time use needs the cache directory created. The scripts detect +its absence and exit `10`; the skill instructs the agent to ask the +user whether to: + +1. **Approve** the default path, +2. **Provide an alternative** workspace root, +3. **Disable** the cache for this repo (recorded in per-developer + auto-memory; sibling-first resolution still works). + +## Scripts + +### `discover` + +``` +discover :: +discover : # version pulled from buildSrc +discover # Spine-only; group + version inferred +``` + +- **stdout** β€” absolute path to a directory you can `Grep`/`Read`. +- **stderr** β€” `STALE` warnings when the sibling's `versionToPublish` + differs from the declared dependency version, plus other + diagnostics. Always inspect. +- **exit 0** β€” path resolved (even if stale; the warning is on stderr). +- **exit 1** β€” unresolvable (missing sibling AND no sources JAR). +- **exit 10** β€” cache uninitialized; run the bootstrap flow. + +### `extract-sources` + +``` +extract-sources :: +``` + +Idempotent and race-safe. If the target directory is already populated +the script returns its path immediately. Concurrent first-time +extractions race on an atomic `mv` of a per-PID temp directory; the +loser discards its temp. + +### `update-sibling` + +``` +update-sibling # resolved under +update-sibling # acts on that path directly +``` + +Invoked by the agent (after user consent) when `discover` emits a +`STALE` warning. Safe by design: + +- Pulls **only** when the sibling is on `master` or `main` with a + clean working tree and a tracked upstream. +- On any other branch, exits `0` without touching anything β€” the + user's "advancing multiple subprojects at once" workflow keeps + feature branches checked out as intentional staging state. +- Refuses on detached HEAD (`3`), uncommitted changes (`4`), or + missing upstream (`5`). +- Never switches branches, never `--rebase`, never `--force`. + +On success (exit `0`), the script writes a single stable token to +**stdout** that names the outcome β€” callers should branch on the +token, not on stderr text. Failure paths produce empty stdout. + +Exit codes: + +| Code | stdout | Meaning | +|---|---|---| +| `0` | `pulled` | HEAD advanced to upstream tip | +| `0` | `up-to-date` | Already at upstream tip; nothing to do | +| `0` | `skipped-branch` | On a non-default branch; left untouched | +| `1` | _(empty)_ | Sibling not on disk | +| `2` | _(empty)_ | Not a git repository | +| `3` | _(empty)_ | Detached HEAD β€” refused | +| `4` | _(empty)_ | Working tree dirty β€” refused | +| `5` | _(empty)_ | No upstream tracking on default branch β€” refused | +| `6` | _(empty)_ | `git pull --ff-only` failed (divergence, network, etc.) | +| `64` | _(empty)_ | Usage error (no/too many arguments) β€” BSD `sysexits(3)` convention | + +### `clean-cache` + +``` +clean-cache --dry-run +clean-cache --older-than 30d [--dry-run] +clean-cache --all [--dry-run] +``` + +Manual pruning only. Nothing runs on a timer. + +## Conventions + +- **Bash 3.2 compatible** β€” macOS ships 3.2 by default. +- **No external dependencies** beyond `bash`, coreutils, `grep`, + `sed`, `awk`, `unzip`, `find`, and `git` (used only by + `update-sibling`). +- **stdout** is always the answer; **stderr** is diagnostics. Mix + them only by piping. +- Scripts source `lib/common.sh` after setting + `SPINE_API_DISCOVERY_DIR`, so the workspace-root pointer file is + reachable. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `cache not initialized` (exit 10) | Bootstrap not run | Follow the skill's bootstrap prompt | +| `sibling not on disk` | Spine repo not cloned | `git clone` it next to your consumer repo | +| `STALE: ...` | Sibling drifted from declared version | Run `update-sibling ` (auto-skips feature branches), or accept the warning | +| `is in the Gradle cache but publishes no -sources.jar` | Upstream artifact has no sources | Read the binary `.class` files via a different tool, or look at GitHub directly | +| `is not in the local Gradle cache` | Gradle has not fetched the dep | `./gradlew dependencies` to populate, then retry | diff --git a/.agents/scripts/api-discovery/clean-cache b/.agents/scripts/api-discovery/clean-cache new file mode 100755 index 000000000..30f604920 --- /dev/null +++ b/.agents/scripts/api-discovery/clean-cache @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# +# Prune the workstation api-discovery extraction cache. +# +# Usage: +# clean-cache --dry-run # list what would be removed (no deletes) +# clean-cache --older-than 30d # remove entries older than 30 days +# clean-cache --older-than 7d --dry-run +# clean-cache --all # wipe the whole sources cache +# clean-cache --all --dry-run +# +# The cache is at +# `/.agents/caches/api-discovery/sources////`. +# "Age" is the directory's mtime (recorded at extraction time). +# +# Exit codes (see lib/common.sh): +# 0 β€” succeeded (with or without removals). +# 1 β€” bad arguments or filesystem error. +# 10 β€” cache not initialized (nothing to clean). +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +SPINE_API_DISCOVERY_DIR="$SCRIPT_DIR" +export SPINE_API_DISCOVERY_DIR +# shellcheck source=lib/common.sh +. "$SCRIPT_DIR/lib/common.sh" + +usage() { + cat >&2 <<'EOF' +Usage: + clean-cache --dry-run + clean-cache --older-than [--dry-run] + clean-cache --all [--dry-run] + +DURATION is a `find -mtime`-style suffix: `7d`, `30d`, `90d` (days only). +EOF + exit "$EX_FAIL" +} + +mode="" +days="" +dry_run=0 + +while [ "$#" -gt 0 ]; do + case "$1" in + --dry-run) + dry_run=1 + shift + ;; + --all) + mode="all" + shift + ;; + --older-than) + mode="older-than" + shift + [ "$#" -gt 0 ] || usage + case "$1" in + *d) days="${1%d}" ;; + *) log_warn "duration must end in 'd' (days), got: $1"; usage ;; + esac + case "$days" in + ''|*[!0-9]*) log_warn "duration days must be numeric, got: $1"; usage ;; + esac + shift + ;; + -h|--help) + usage + ;; + *) + log_warn "unknown argument: $1" + usage + ;; + esac +done + +# Default to a no-op listing if no mode was given. +[ -n "$mode" ] || mode="older-than-default" + +if ! cache_initialized; then + log_warn "cache not initialized: $(cache_root)" + exit "$EX_NO_CACHE" +fi + +sources="$(sources_root)" + +# Collect targets into a temp list so we can preview and act consistently. +list_file="$(mktemp -t api-discovery-clean.XXXXXX)" +trap 'rm -f -- "$list_file"' EXIT + +case "$mode" in + all) + find "$sources" -mindepth 3 -maxdepth 3 -type d -print > "$list_file" 2>/dev/null || true + ;; + older-than) + find "$sources" -mindepth 3 -maxdepth 3 -type d -mtime "+$days" -print \ + > "$list_file" 2>/dev/null || true + ;; + older-than-default) + log_warn "no mode specified; use --all or --older-than d" + usage + ;; +esac + +count="$(wc -l < "$list_file" | tr -d '[:space:]')" + +# Prune now-empty `/` and `//` parents so the cache +# layout stays tidy. Two passes (artifact dirs first, then group dirs) so +# that emptying a group's last artifact also reclaims the group dir. +# Skipped under --dry-run so the command stays read-only. +prune_empty_parents() { + find "$sources" -mindepth 2 -maxdepth 2 -type d -empty -exec rmdir -- {} + \ + 2>/dev/null || true + find "$sources" -mindepth 1 -maxdepth 1 -type d -empty -exec rmdir -- {} + \ + 2>/dev/null || true +} + +if [ "$count" -eq 0 ]; then + log_warn "no entries match; cache untouched" + [ "$dry_run" -eq 0 ] && prune_empty_parents + exit "$EX_OK" +fi + +if [ "$dry_run" -eq 1 ]; then + log_warn "would remove $count entr$( [ "$count" -eq 1 ] && printf 'y' || printf 'ies' ):" + cat -- "$list_file" + exit "$EX_OK" +fi + +removed=0 +while IFS= read -r path; do + [ -n "$path" ] || continue + if rm -rf -- "$path"; then + removed=$((removed + 1)) + else + log_warn "failed to remove: $path" + fi +done < "$list_file" + +prune_empty_parents + +log_warn "removed $removed entr$( [ "$removed" -eq 1 ] && printf 'y' || printf 'ies' )" +exit "$EX_OK" diff --git a/.agents/scripts/api-discovery/discover b/.agents/scripts/api-discovery/discover new file mode 100755 index 000000000..2b69d36ba --- /dev/null +++ b/.agents/scripts/api-discovery/discover @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# +# Resolve the on-disk location of source code for a Maven artifact, so +# agents can `Grep`/`Read` it directly instead of repeatedly `unzip`ing +# sources JARs out of the Gradle cache. +# +# Strategy: +# 1. Spine artifacts (group = io.spine / io.spine.tools / etc.) are +# served from a sibling clone under `//`. The +# sibling is identified from the `github.com/SpineEventEngine/` +# URL inside the matching `buildSrc/.../dependency/local/.kt` +# file. Submodule paths are resolved by trying canonical name +# transformations (see `resolve_submodule_path`). +# 2. Anything else (and Spine fallbacks when no sibling is on disk) +# is served from the per-workstation extraction cache populated by +# `extract-sources`. +# +# Usage: +# discover :: +# discover : +# discover +# +# Output: +# stdout: absolute path to a directory containing the source tree. +# stderr: freshness warnings and explanatory diagnostics. Always +# inspect stderr before relying on the resolved path. +# +# Exit codes (see lib/common.sh): +# 0 β€” path resolved (path on stdout). +# 1 β€” unresolvable (sibling missing AND extraction failed). +# 10 β€” workstation cache directory not initialized AND the query +# requires the cache (i.e. sibling-first did not succeed). +# Spine-sibling resolution never triggers EX_NO_CACHE β€” the +# skill's "Non-cached" mode keeps working without bootstrap. +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +SPINE_API_DISCOVERY_DIR="$SCRIPT_DIR" +export SPINE_API_DISCOVERY_DIR +# shellcheck source=lib/common.sh +. "$SCRIPT_DIR/lib/common.sh" + +usage() { + cat >&2 <<'EOF' +Usage: discover + +Where is one of: + group:artifact:version e.g. io.spine:spine-base:2.0.0-SNAPSHOT.390 + group:artifact e.g. io.spine:spine-base + artifact e.g. spine-base + +Spine artifacts resolve to the local sibling clone; non-Spine +artifacts resolve to the extracted-sources cache. +EOF + exit "$EX_FAIL" +} + +[ "$#" -eq 1 ] || usage + +parse_query "$1" +group="$Q_GROUP" +artifact="$Q_ARTIFACT" +version="$Q_VERSION" + +if [ -z "$artifact" ]; then + log_warn "empty artifact in query: $1" + exit "$EX_FAIL" +fi + +# NOTE: We intentionally do NOT check `cache_initialized` here. The +# Spine-sibling path doesn't need the cache, and the skill's +# "Non-cached" bootstrap option must keep that path working without +# bootstrap. If we end up falling through to `extract-sources`, that +# script enforces the cache check on its own and raises EX_NO_CACHE. + +# Try the sibling path for Spine artifacts. If the group is empty we +# still attempt the local-deps lookup; a hit means it's a Spine artifact. +try_sibling() { + local dep_file + dep_file="$(find_local_dep_file_for_artifact "$artifact")" + [ -n "$dep_file" ] || return 1 + + local sibling_name workspace sibling_path + sibling_name="$(sibling_name_from_dep_file "$dep_file")" + workspace="$(workspace_root)" || return 1 + sibling_path="$workspace/$sibling_name" + + if [ ! -d "$sibling_path" ]; then + log_warn "sibling not on disk: $sibling_path (declared in $dep_file)" + return 1 + fi + + local module_path + module_path="$(resolve_submodule_path "$sibling_path" "$artifact")" + + if [ ! -d "$module_path" ]; then + log_warn "resolved submodule path missing: $module_path" + return 1 + fi + + # Freshness check: declared version vs sibling's published version. + local declared sibling_v + declared="${version:-$(read_declared_version "$dep_file" || true)}" + sibling_v="$(read_sibling_version "$sibling_path" 2>/dev/null || true)" + + if [ -n "$declared" ] && [ -n "$sibling_v" ] && \ + [ "$declared" != "$sibling_v" ]; then + log_warn "STALE: $artifact declared $declared in $(basename -- "$dep_file") but sibling publishes $sibling_v" + log_warn "sources at $module_path may differ from the published artifact" + fi + + printf '%s\n' "$module_path" + return 0 +} + +# Try sibling first when it could plausibly be a Spine artifact. +if [ -z "$group" ] || is_spine_group "$group"; then + if try_sibling; then + exit "$EX_OK" + fi +fi + +# Fall back to the extraction cache. This needs a full coordinate. +if [ -z "$group" ] || [ -z "$artifact" ] || [ -z "$version" ]; then + # Try to fill in the missing pieces from a local dep file. + if [ -z "$version" ]; then + dep_file="$(find_local_dep_file_for_artifact "$artifact" || true)" + if [ -n "$dep_file" ]; then + version="$(read_declared_version "$dep_file" || true)" + fi + fi + if [ -z "$group" ]; then + # The local Spine objects all use Spine.group / Spine.toolsGroup. + # Without a sibling hit we can't disambiguate; require explicit group. + log_warn "cannot resolve $artifact without a Maven group" + log_warn "retry with :[:]" + exit "$EX_FAIL" + fi + if [ -z "$version" ]; then + log_warn "cannot resolve $group:$artifact without a version" + log_warn "retry with ::" + exit "$EX_FAIL" + fi +fi + +# Delegate to extract-sources. It handles "already cached" by returning +# the path immediately, so this path is fast on repeat queries. +# The `|| exit $?` idiom propagates extract-sources' exit code under +# `set -e`; do NOT split into `target=...; status=$?` β€” `set -e` may +# terminate the script before the status check runs. +target="$("$SCRIPT_DIR/extract-sources" "$group:$artifact:$version")" || exit $? + +printf '%s\n' "$target" diff --git a/.agents/scripts/api-discovery/extract-sources b/.agents/scripts/api-discovery/extract-sources new file mode 100755 index 000000000..a8456680a --- /dev/null +++ b/.agents/scripts/api-discovery/extract-sources @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# +# Extract a `-sources.jar` from the local Gradle cache into the workstation +# api-discovery cache. Idempotent and race-safe: a second invocation that +# observes an existing target returns immediately; concurrent first-time +# extractions race on the atomic `mv` of a per-PID temp directory. +# +# Usage: +# extract-sources :: +# +# Output: +# stdout: absolute path to the extracted source tree. +# stderr: explanatory diagnostics on cache misses or failures. +# +# Exit codes (see lib/common.sh for the shared definitions): +# 0 β€” extraction successful (or already cached). +# 1 β€” sources unavailable (missing JAR, no `-sources` variant, etc.). +# 10 β€” workstation cache directory not initialized. +# +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +SPINE_API_DISCOVERY_DIR="$SCRIPT_DIR" +export SPINE_API_DISCOVERY_DIR +# shellcheck source=lib/common.sh +. "$SCRIPT_DIR/lib/common.sh" + +usage() { + cat >&2 <<'EOF' +Usage: extract-sources :: + +Extracts the matching `-sources.jar` from the local Gradle cache into +`/.agents/caches/api-discovery/sources////`. +EOF + exit "$EX_FAIL" +} + +[ "$#" -eq 1 ] || usage + +parse_query "$1" +group="$Q_GROUP" +artifact="$Q_ARTIFACT" +version="$Q_VERSION" + +if [ -z "$group" ] || [ -z "$artifact" ] || [ -z "$version" ]; then + log_warn "extract-sources requires a full coordinate ::" + exit "$EX_FAIL" +fi + +if ! cache_initialized; then + log_warn "cache not initialized: $(cache_root)" + log_warn "run the api-discovery bootstrap flow to create it" + exit "$EX_NO_CACHE" +fi + +target="$(sources_root)/$group/$artifact/$version" + +if [ -d "$target" ] && [ -n "$(ls -A -- "$target" 2>/dev/null)" ]; then + printf '%s\n' "$target" + exit "$EX_OK" +fi + +sources_jar="$(find_gradle_cache_jar "$group" "$artifact" "$version" -sources)" +if [ -z "$sources_jar" ]; then + if [ -n "$(find_gradle_cache_jar "$group" "$artifact" "$version")" ]; then + log_warn "$group:$artifact:$version is in the Gradle cache but publishes no -sources.jar" + exit "$EX_FAIL" + fi + log_warn "$group:$artifact:$version is not in the local Gradle cache" + log_warn "run './gradlew dependencies' (or rebuild) to fetch it, then retry" + exit "$EX_FAIL" +fi + +parent="$(dirname -- "$target")" +mkdir -p -- "$parent" + +# Race-safe extraction: +# 1) extract into a sibling temp dir whose name embeds our PID, +# 2) check that the final target does not yet exist (race-lost +# detection); if it does, drop our temp and use the existing tree, +# 3) otherwise `mv tmp target`. Note that on macOS/Linux, `mv` into an +# existing directory silently moves the source INSIDE it β€” we cannot +# rely on `mv` failing when the race is lost. The pre-test catches +# the common case; step 4 catches the narrow window between test +# and mv. +# 4) post-mv, detect the rare race where `target` materialized between +# our existence test and the mv: in that case `target/` +# now exists; remove it. +tmp="${target}.tmp.$$" +trap 'rm -rf -- "$tmp"' EXIT +rm -rf -- "$tmp" +mkdir -p -- "$tmp" + +if ! unzip -q -o -- "$sources_jar" -d "$tmp"; then + log_warn "unzip failed for $sources_jar" + exit "$EX_FAIL" +fi + +if [ -e "$target" ]; then + # Another process beat us to it. Discard our temp; use theirs. + rm -rf -- "$tmp" +else + if ! mv -- "$tmp" "$target"; then + log_warn "could not move extracted sources into $target" + exit "$EX_FAIL" + fi + # Close the narrow race window: if `target` appeared between the + # existence test and the mv, `mv` silently moved `$tmp` INSIDE the + # winning extraction. Clean up the nested debris. + nested="$target/$(basename -- "$tmp")" + if [ -e "$nested" ]; then + rm -rf -- "$nested" + fi +fi +trap - EXIT + +log_warn "extracted $group:$artifact:$version -> $target" +printf '%s\n' "$target" diff --git a/.agents/scripts/api-discovery/lib/common.sh b/.agents/scripts/api-discovery/lib/common.sh new file mode 100644 index 000000000..fa3e9c833 --- /dev/null +++ b/.agents/scripts/api-discovery/lib/common.sh @@ -0,0 +1,364 @@ +#!/usr/bin/env bash +# +# Shared helpers for the api-discovery scripts. +# Sourced by ../discover, ../extract-sources, ../clean-cache. +# +# All functions write diagnostics to stderr; "return values" go to stdout. +# +# Conventions: +# - Bash 3.2 compatible (macOS default). +# - No external deps beyond coreutils, grep, sed, unzip. +# - `set -euo pipefail` is set by the caller, not here. + +# Exit codes used across the scripts. +readonly EX_OK=0 +readonly EX_FAIL=1 +readonly EX_NO_CACHE=10 # cache uninitialized; agent runs bootstrap + +# Resolve the consumer repository root β€” the nearest ancestor of $PWD +# containing `buildSrc/src/main/kotlin/io/spine/dependency/`. Falls back to +# CLAUDE_PROJECT_DIR (set by Claude Code) if no such ancestor exists. +# Prints the absolute path to stdout. Exits non-zero if it cannot resolve. +consumer_repo_root() { + local dir="${1:-$PWD}" + while [ "$dir" != "/" ] && [ -n "$dir" ]; do + if [ -d "$dir/buildSrc/src/main/kotlin/io/spine/dependency" ]; then + printf '%s\n' "$dir" + return 0 + fi + dir="$(dirname -- "$dir")" + done + if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && \ + [ -d "$CLAUDE_PROJECT_DIR/buildSrc/src/main/kotlin/io/spine/dependency" ]; then + printf '%s\n' "$CLAUDE_PROJECT_DIR" + return 0 + fi + return 1 +} + +# Resolve the workspace root (parent of the consumer repo by default). +# Honors an optional pointer file at +# `/.workspace-root` containing an absolute path; used when the +# user picks "alternative root" during bootstrap. +workspace_root() { + local scripts_dir="${SPINE_API_DISCOVERY_DIR:-}" + if [ -z "$scripts_dir" ]; then + local repo + repo="$(consumer_repo_root)" || return 1 + scripts_dir="$repo/.agents/scripts/api-discovery" + fi + local pointer="$scripts_dir/.workspace-root" + if [ -f "$pointer" ]; then + # Read the first line verbatim. `IFS=` keeps internal spaces + # (paths like `/Users/me/Spine Workspace` must survive intact); + # `read -r` strips the trailing newline. Strip a stray CR for + # Windows-style line endings. + local custom="" + IFS= read -r custom < "$pointer" 2>/dev/null || true + custom="${custom%$'\r'}" + if [ -n "$custom" ] && [ -d "$custom" ]; then + printf '%s\n' "$custom" + return 0 + fi + fi + local repo + repo="$(consumer_repo_root)" || return 1 + (cd "$repo/.." && pwd) +} + +# Directory that holds the per-workstation api-discovery cache. +cache_root() { + local ws + ws="$(workspace_root)" || return 1 + printf '%s/.agents/caches/api-discovery\n' "$ws" +} + +# Subdirectory under cache_root where extracted sources live. +sources_root() { + local root + root="$(cache_root)" || return 1 + printf '%s/sources\n' "$root" +} + +# Returns 0 if the sources cache directory exists; 1 otherwise. +cache_initialized() { + local s + s="$(sources_root)" || return 1 + [ -d "$s" ] +} + +# Returns the first Gradle-cache JAR path matching the coordinates and +# optional suffix ("-sources" or empty). Empty stdout means "not found". +find_gradle_cache_jar() { + local group="$1" artifact="$2" version="$3" suffix="${4:-}" + local base="$HOME/.gradle/caches/modules-2/files-2.1/$group/$artifact/$version" + [ -d "$base" ] || return 0 + local jar + jar="$(find "$base" -maxdepth 2 -type f \ + -name "${artifact}-${version}${suffix}.jar" 2>/dev/null \ + | head -n 1)" + [ -n "$jar" ] && printf '%s\n' "$jar" + return 0 +} + +# Extract the canonical `const val version` value from a Spine local/.kt +# file. Anchors at line start (with optional access modifier) so that +# `const val version` strings inside KDoc, comments, or other quoted text +# do not match. Each local/.kt is expected to declare exactly one +# top-level `version` constant; multi-artifact files use different +# constant names (e.g. `mcVersion`) for their non-canonical versions. +read_declared_version() { + local file="$1" + [ -f "$file" ] || return 1 + sed -nE 's/^[[:space:]]*(private[[:space:]]+|internal[[:space:]]+|public[[:space:]]+|protected[[:space:]]+)?const[[:space:]]+val[[:space:]]+version[[:space:]]*=[[:space:]]*"([^"]+)".*/\2/p' \ + "$file" | head -n 1 +} + +# Read a `val [: Type] by extra("VALUE")` declaration from a file. +# Prints VALUE on stdout; empty if not found. +_read_extra_val() { + local file="$1" name="$2" + sed -nE 's/^[[:space:]]*val[[:space:]]+'"$name"'([[:space:]]*:[[:space:]]*[A-Za-z]+)?[[:space:]]+by[[:space:]]+extra\("([^"]+)"\).*/\2/p' \ + "$file" | head -n 1 +} + +# Read the sibling's "main" version from `/version.gradle.kts`. +# Tries (in order): +# 1. `versionToPublish` β€” canonical name used by most siblings. +# 2. `Version` β€” e.g. `mcJavaVersion` +# for sibling `mc-java`, `protoDataVersion` for `ProtoData`. +# Returns non-zero if neither is found; callers treat that as +# "freshness check unavailable". +read_sibling_version() { + local sibling="$1" + local file="$sibling/version.gradle.kts" + [ -f "$file" ] || return 1 + + local v + v="$(_read_extra_val "$file" "versionToPublish")" + if [ -n "$v" ]; then + printf '%s\n' "$v" + return 0 + fi + + local sibling_name camel + sibling_name="$(basename -- "$sibling")" + camel="$(camel_case_lower "$sibling_name")Version" + v="$(_read_extra_val "$file" "$camel")" + if [ -n "$v" ]; then + printf '%s\n' "$v" + return 0 + fi + + return 1 +} + +# Convert a PascalCase name to kebab-case. +# Examples: Base -> base; CoreJvm -> core-jvm; CoreJvmCompiler -> core-jvm-compiler. +kebab_case() { + printf '%s\n' "$1" | sed -E 's/([a-z0-9])([A-Z])/\1-\2/g; s/([A-Z]+)([A-Z][a-z])/\1-\2/g' \ + | tr '[:upper:]' '[:lower:]' +} + +# Convert a kebab-case or PascalCase name to camelCase (first letter lowercase). +# Examples: base-libraries -> baseLibraries; mc-java -> mcJava; +# core-jvm-compiler -> coreJvmCompiler; ProtoData -> protoData. +camel_case_lower() { + local input="$1" + local pascal + pascal="$(printf '%s\n' "$input" | awk -F- '{ + out="" + for (i = 1; i <= NF; i++) { + out = out toupper(substr($i, 1, 1)) substr($i, 2) + } + print out + }')" + local first rest + first="$(printf '%s' "$pascal" | cut -c1 | tr '[:upper:]' '[:lower:]')" + rest="$(printf '%s' "$pascal" | cut -c2-)" + printf '%s%s\n' "$first" "$rest" +} + +# Given a Spine local/.kt file, deduce its sibling repository name. +# Priority: +# 1. `https://github.com/SpineEventEngine/` URL inside the file. +# 2. kebab-case of the file's basename (without `.kt`). +sibling_name_from_dep_file() { + local file="$1" + [ -f "$file" ] || return 1 + local from_url + from_url="$(sed -nE 's|.*github\.com/SpineEventEngine/([A-Za-z0-9._-]+).*|\1|p' \ + "$file" | head -n 1)" + if [ -n "$from_url" ]; then + # Trim any trailing slash or punctuation. + from_url="${from_url%/}" + printf '%s\n' "$from_url" + return 0 + fi + local base + base="$(basename -- "$file" .kt)" + kebab_case "$base" +} + +# Returns 0 if the given directory contains a Kotlin/Java source set. +# Recognizes plain `src/main` and Kotlin Multiplatform names such as +# `src/commonMain`, `src/jvmMain`, `src/jsMain`, `src/nativeMain`. +has_source_set() { + local dir="$1" + [ -d "$dir/src" ] || return 1 + local candidate + for candidate in main commonMain jvmMain jsMain nativeMain; do + [ -d "$dir/src/$candidate" ] && return 0 + done + return 1 +} + +# Resolve a submodule inside a sibling that owns a given artifact. +# Tries candidate subdirectory names in order, returning the first that +# contains a recognizable source set: +# 1. Sibling root (single-module siblings such as `reflect`, `testlib`). +# 2. `/` (artifact name == submodule name). +# 3. `/` +# (`spine-base` -> `base-libraries/base`). +# 4. `/-`-stripped>` +# (`spine-protodata-backend` -> `ProtoData/backend`). +# 5. `/` +# (covers `spine-tool-base` -> `tool-base/tool-base`). +# Falls back to the sibling root when no candidate matches. +resolve_submodule_path() { + local sibling="$1" artifact="$2" + [ -d "$sibling" ] || return 1 + + if has_source_set "$sibling"; then + printf '%s\n' "$sibling" + return 0 + fi + + local sibling_name lower_sibling + sibling_name="$(basename -- "$sibling")" + lower_sibling="$(printf '%s' "$sibling_name" | tr '[:upper:]' '[:lower:]')" + + local candidates=() + candidates+=("$artifact") + + case "$artifact" in + spine-*) candidates+=("${artifact#spine-}") ;; + esac + + case "$artifact" in + spine-${lower_sibling}-*) candidates+=("${artifact#spine-${lower_sibling}-}") ;; + ${lower_sibling}-*) candidates+=("${artifact#${lower_sibling}-}") ;; + esac + + candidates+=("$sibling_name") + + local cand + for cand in "${candidates[@]}"; do + [ -n "$cand" ] || continue + if has_source_set "$sibling/$cand"; then + printf '%s/%s\n' "$sibling" "$cand" + return 0 + fi + done + + printf '%s\n' "$sibling" +} + +# Identify whether a Maven group belongs to the Spine sibling ecosystem. +# Returns 0 (true) for Spine groups, 1 (false) otherwise. +is_spine_group() { + case "$1" in + io.spine|io.spine.tools|io.spine.protodata|io.spine.validation) + return 0 + ;; + *) + return 1 + ;; + esac +} + +# Parse a query into group, artifact, version. Sets the globals +# Q_GROUP, Q_ARTIFACT, Q_VERSION. Some may be empty. +# Accepts: `group:artifact:version`, `group:artifact`, `artifact`, or a +# free-form name that we treat as either an artifact or a sibling label. +# Returns 0 always; the caller decides what an empty field means. +parse_query() { + local q="$1" + Q_GROUP=""; Q_ARTIFACT=""; Q_VERSION="" + local rest + case "$q" in + *:*:*) + Q_GROUP="${q%%:*}" + rest="${q#*:}" + Q_ARTIFACT="${rest%%:*}" + Q_VERSION="${rest#*:}" + ;; + *:*) + Q_GROUP="${q%%:*}" + Q_ARTIFACT="${q#*:}" + ;; + *) + Q_ARTIFACT="$q" + ;; + esac +} + +# Escape every non-alphanumeric character so the result is safe to embed +# in a POSIX ERE pattern. Cheap overkill β€” Maven artifact names should +# never need most of these, but the caller's input is untrusted. +escape_ere() { + printf '%s' "$1" | sed 's/[^A-Za-z0-9]/\\&/g' +} + +# Locate the consumer repo's local/.kt file that declares a Maven artifact. +# Some local files build artifact coordinates via Kotlin string templates +# (`"$prefix-base"`, `"$group:$prefix-java:$version"`). To match those we +# expand the per-file `prefix` constant before grepping. Other template +# variables resolve to literals already present in the source, so a plain +# grep finds them. +# Returns the path of the first matching file (or empty). +find_local_dep_file_for_artifact() { + local artifact="$1" + local repo + repo="$(consumer_repo_root)" || return 1 + local local_dir="$repo/buildSrc/src/main/kotlin/io/spine/dependency/local" + [ -d "$local_dir" ] || return 0 + + # Validate the artifact name against the Maven convention before + # building a regex from it. Reject anything we cannot guarantee is + # safe; this prevents shell-quoted regex metacharacters in + # caller-supplied input from being interpreted by `grep -E`. + case "$artifact" in + ''|*[!A-Za-z0-9._-]*) + log_warn "invalid artifact name (allowed: A-Z a-z 0-9 . _ -): $artifact" + return 1 + ;; + esac + local artifact_esc + artifact_esc="$(escape_ere "$artifact")" + + local file prefix expanded + for file in "$local_dir"/*.kt; do + [ -f "$file" ] || continue + prefix="$(sed -nE 's/.*const[[:space:]]+val[[:space:]]+prefix[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' \ + "$file" | head -1)" + if [ -n "$prefix" ]; then + expanded="$(sed -e 's|\$prefix|'"$prefix"'|g; s|\${prefix}|'"$prefix"'|g' "$file")" + else + expanded="$(cat -- "$file")" + fi + # Match the artifact as a complete coordinate component: + # delimited by `"`, `:`, or `-` on either side, never as a substring. + if printf '%s\n' "$expanded" | grep -qE "[\":-]${artifact_esc}[\":-]|[\":-]${artifact_esc}\$|^${artifact_esc}\$"; then + printf '%s\n' "$file" + return 0 + fi + done + return 0 +} + +# Emit a stderr line tagged with the scripts' identity, so the agent can +# distinguish them from unrelated noise. +log_warn() { + printf 'api-discovery: %s\n' "$*" >&2 +} diff --git a/.agents/scripts/api-discovery/update-sibling b/.agents/scripts/api-discovery/update-sibling new file mode 100755 index 000000000..e145aaee6 --- /dev/null +++ b/.agents/scripts/api-discovery/update-sibling @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# +# Refresh a Spine sibling repo on disk so api-discovery returns sources +# matching the most recent published version. +# +# Safe by design: +# - Pulls only when the sibling is on its default branch (master or +# main) with a clean working tree and a tracked upstream. +# - On any other branch, treats the local state as intentional (the +# user is "advancing multiple subprojects at the same time") and +# exits 0 without touching anything. +# - Refuses on detached HEAD, uncommitted changes, or missing upstream. +# - Never switches branches, never `--rebase`, never `--force`, never +# fetches a branch the user does not already track. The strictest +# action it performs is `git pull --ff-only`. +# +# This script is intended to be invoked by the api-discovery skill +# after the agent has surfaced a STALE warning to the user AND received +# explicit consent. +# +# Usage: +# update-sibling # resolved under +# update-sibling # acts on that path directly +# +# Output: +# stdout: exactly one stable token on success (`pulled`, `up-to-date`, +# or `skipped-branch` β€” see Exit codes). Empty on any failure +# path so callers cannot misread an error as a result. +# stderr: human-facing diagnostics, including git's own pull output. +# +# Exit codes: +# 0 β€” succeeded; stdout token names the outcome: +# `pulled` β€” HEAD advanced to upstream tip. +# `up-to-date` β€” already at upstream tip; nothing to do. +# `skipped-branch` β€” on a non-default branch; left untouched. +# 1 β€” sibling not on disk. +# 2 β€” not a git repository. +# 3 β€” detached HEAD; refused. +# 4 β€” uncommitted tracked changes; refused. +# 5 β€” default branch has no upstream tracking; refused. +# 6 β€” `git pull --ff-only` failed (divergence, conflict, network, etc.). +# 64 β€” usage error (no/too many args). +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +SPINE_API_DISCOVERY_DIR="$SCRIPT_DIR" +export SPINE_API_DISCOVERY_DIR +# shellcheck source=lib/common.sh +. "$SCRIPT_DIR/lib/common.sh" + +# Layered on top of the shared EX_OK / EX_FAIL / EX_NO_CACHE in common.sh. +readonly EX_NOT_GIT=2 +readonly EX_DETACHED=3 +readonly EX_DIRTY=4 +readonly EX_NO_UPSTREAM=5 +readonly EX_PULL_FAILED=6 +readonly EX_USAGE=64 # BSD sysexits(3) convention. + +usage() { + cat >&2 <<'EOF' +Usage: update-sibling + +Examples: + update-sibling base-libraries + update-sibling /Users/me/Projects/Spine/validation + +Pulls only when the sibling is on its default branch (master|main) +with a clean working tree and a tracked upstream. Otherwise it leaves +the sibling alone. + +On success, prints one of: pulled | up-to-date | skipped-branch. +EOF + exit "$EX_USAGE" +} + +[ "$#" -eq 1 ] || usage +arg="$1" + +# Resolve to an absolute sibling path. Accept either a bare repo name +# (looked up under ) or an absolute path. +case "$arg" in + /*) sibling="$arg" ;; + *) + ws="$(workspace_root)" || { + log_warn "cannot resolve workspace root" + exit "$EX_FAIL" + } + sibling="$ws/$arg" + ;; +esac + +if [ ! -d "$sibling" ]; then + log_warn "sibling not on disk: $sibling" + exit "$EX_FAIL" # distinct from EX_USAGE so callers can tell + # "you passed me a bad path" apart from + # "you didn't pass me anything". +fi + +# `.git` may be a directory (normal clone) or a file (worktree pointer). +if [ ! -e "$sibling/.git" ]; then + log_warn "not a git repository: $sibling" + exit "$EX_NOT_GIT" +fi + +branch="$(git -C "$sibling" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +if [ -z "$branch" ]; then + log_warn "failed to read current branch in $sibling" + exit "$EX_FAIL" +fi +if [ "$branch" = "HEAD" ]; then + log_warn "$sibling is in detached HEAD; not pulling" + exit "$EX_DETACHED" +fi + +# Non-default branch: the user is intentionally on a feature branch. +# Use the current code as-is. +case "$branch" in + master|main) + ;; + *) + log_warn "$sibling is on '$branch' (not master/main); using local code as-is" + printf 'skipped-branch\n' + exit "$EX_OK" + ;; +esac + +# Dirty-tree guard: refuse on TRACKED modifications, since a fast-forward +# could conflict with them. Untracked files are tolerated: they don't +# conflict with HEAD movement on their own, and any genuine overwrite +# conflict (upstream adds a file whose path already exists untracked +# locally) still surfaces below as EX_PULL_FAILED via git's own check. +if [ -n "$(git -C "$sibling" status --porcelain --untracked-files=no 2>/dev/null)" ]; then + log_warn "$sibling has uncommitted tracked changes on '$branch'; not pulling" + exit "$EX_DIRTY" +fi + +# Upstream guard: --ff-only against an undefined upstream is meaningless. +if ! git -C "$sibling" rev-parse --abbrev-ref --symbolic-full-name '@{u}' \ + >/dev/null 2>&1; then + log_warn "$sibling/$branch has no upstream tracking; not pulling" + exit "$EX_NO_UPSTREAM" +fi + +# Capture HEAD before/after so we can report what changed. +before="$(git -C "$sibling" rev-parse HEAD)" +if ! git -C "$sibling" pull --ff-only >&2; then + log_warn "git pull --ff-only failed in $sibling" + exit "$EX_PULL_FAILED" +fi +after="$(git -C "$sibling" rev-parse HEAD)" + +if [ "$before" = "$after" ]; then + log_warn "$sibling already up-to-date on '$branch' ($after)" + printf 'up-to-date\n' +else + log_warn "$sibling pulled '$branch': $before -> $after" + printf 'pulled\n' +fi +exit "$EX_OK" diff --git a/.agents/scripts/pre-pr-gate.sh b/.agents/scripts/pre-pr-gate.sh new file mode 100755 index 000000000..cb80b3125 --- /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 <&2 <<'EOF' +Direct edits to version.gradle.kts are blocked by a project hook. + +If this repository already has a root version.gradle.kts, use the bump-version +skill instead: + /bump-version [snapshot|minor|major] + +If this repository does not have a root version.gradle.kts, do not add one just +to satisfy /pre-pr; the version check is not applicable. + +See: + - .agents/version-policy.md + - .agents/skills/bump-version/SKILL.md +EOF + exit 2 +fi + +exit 0 diff --git a/.agents/scripts/publish-version-gate.sh b/.agents/scripts/publish-version-gate.sh new file mode 100755 index 000000000..466f0a800 --- /dev/null +++ b/.agents/scripts/publish-version-gate.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# +# PreToolUse hook: block any `./gradlew` invocation that could publish to +# Maven Local without a version bump on the current branch. Wraps the +# Layer-1 deterministic check at `version-bumped.sh`. +# +# This is intentionally broad: it fires on `build`, `publish`, +# `publishToMavenLocal`, and any `:publish*` task. Many repos in this +# constellation chain `publishToMavenLocal` into `build` because +# integration tests consume those local artifacts, so `build` itself is +# publish-risky. False positives (blocking a pure compile) are preferable +# to overwriting a previously published snapshot that consuming repos +# rely on. +# +# 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 (`;`, `&`, `|`) and inspect each +# segment. Only block when a segment, after optional whitespace, invokes +# `./gradlew` (or `./config/gradlew`) with a publish-risky task. Avoids +# false positives on `echo "./gradlew build"` or fixtures. +risky_segment() { + local seg="$1" + # Must start with a gradlew invocation. + printf '%s' "$seg" | grep -qE '^[[:space:]]*\.?/?(config/)?gradlew([[:space:]]|$)' || return 1 + # Must mention a publish-risky task. `build` is risky because it can + # finalize publishToMavenLocal in this config. The leading + # `(:[A-Za-z0-9_.-]+)*:?` covers qualified task paths + # (e.g. `:module:build`, `:a:b:publishToMavenLocal`) and a single + # leading-colon form (`:publishMavenJavaPublicationToMavenLocal`). + # `publish[^[:space:]]*` then catches every publish-task variant. + printf '%s' "$seg" | grep -qE '(^|[[:space:]])(:[A-Za-z0-9_.-]+)*:?(build|publish[^[:space:]]*|publishToMavenLocal|publishAllPublicationsToMavenLocal)([[:space:]]|$)' +} + +block_needed=0 +# `|| [ -n "$segment" ]` makes the loop process the final segment when the +# input has no trailing newline (which is the case for `printf '%s'`). +while IFS= read -r segment || [ -n "$segment" ]; do + if risky_segment "$segment"; then + block_needed=1 + break + fi +done < <(printf '%s' "$cmd" | tr ';&|' '\n\n\n') + +[ "$block_needed" -eq 0 ] && exit 0 + +repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 +script="$repo_root/.agents/skills/version-bumped/scripts/version-bumped.sh" + +# If the helper is missing (e.g. partial clone), don't pretend we gated. +if [ ! -x "$script" ]; then + exit 0 +fi + +# `&& rc=0 || rc=$?` captures the exit code regardless of success/failure. +# After `if cmd; then ... fi`, $? reflects the if-fi structural exit (0), +# not the failed test's exit code β€” so we cannot use the if-fi form here. +err_file="/tmp/version-bumped.$$.err" +VERSION_BUMPED_QUIET=1 "$script" 2>"$err_file" && rc=0 || rc=$? +if [ "$rc" -eq 0 ]; then + rm -f "$err_file" + exit 0 +fi +err_payload=$(cat "$err_file" 2>/dev/null || true) +rm -f "$err_file" + +# Layer-1 returned a configuration error β€” do not block, surface the note. +if [ "$rc" -ne 1 ]; then + printf '%s\n' "$err_payload" >&2 + exit 0 +fi + +cat >&2 < 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 000000000..b25282fda --- /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/api-discovery/SKILL.md b/.agents/skills/api-discovery/SKILL.md new file mode 100644 index 000000000..b1622ffd1 --- /dev/null +++ b/.agents/skills/api-discovery/SKILL.md @@ -0,0 +1,288 @@ +--- +name: api-discovery +description: > + Resolve the on-disk location of a Maven artifact's source code, + so you can `Grep`/`Read` it directly instead of running `unzip` + against JARs in the Gradle cache. Use this whenever you need to + inspect a library's API or implementation β€” definitions of public + types, method signatures, KDoc, internal helpers, etc. +--- + +# API discovery + +Before reading library source code, run the `discover` script in +`.agents/scripts/api-discovery/`. It returns a path you can hand +straight to `Grep`, `Read`, or `Glob`. + +Do **not** run `find ~/.gradle/caches` or `unzip` against cache JARs. +Each `unzip` decompresses the archive afresh β€” slow and token-heavy. + +## How to call it + +From the consumer repository root: + +```bash +.agents/scripts/api-discovery/discover +``` + +Where `` is one of: + +| Form | Example | Notes | +|---|---|---| +| `group:artifact:version` | `io.spine:spine-base:2.0.0-SNAPSHOT.390` | Most explicit | +| `group:artifact` | `io.spine:spine-base` | Version inferred from `buildSrc` | +| `artifact` | `spine-base` | Spine-only; group inferred from `buildSrc` | + +The script writes the absolute resolved path to **stdout**, and any +freshness/diagnostic warnings to **stderr**. Always read stderr β€” a +silent stdout means clean resolution; a noisy stderr means caveats +the user should know about. + +## Exit codes + +| Code | Meaning | What you do | +|---|---|---| +| `0` | Path on stdout is usable. | Pass it to `Grep`/`Read`/`Glob`. If stderr is non-empty, surface the warning to the user before relying on the path. | +| `1` | Unresolvable (no sibling AND no JAR). | Report the failure. **Do not** fall back to `unzip ~/.gradle/caches/...`. | +| `10` | Cache directory not initialized. | Run the **bootstrap flow** below. | + +## Bootstrap flow (exit 10) + +On the first run in a fresh workstation the per-workstation cache +directory does not yet exist. The script exits `10` and names the +path it would create. Ask the user: + +> The shared cache directory `/.agents/caches/api-discovery/` +> does not exist yet. How would you like to proceed? +> +> 1. **Approve** β€” create the directory at the default path. +> 2. **Alternative root** β€” pick a different parent for the shared +> `.agents/` directory (e.g., `~/SpineWorkspace`, `/srv/spine`). +> 3. **Non-cached** β€” skip the extraction cache. Sibling-first +> discovery still works for Spine artifacts; non-Spine deps will +> not be served by `api-discovery` in this repo. + +Then act on the user's reply: + +- **Approve** β†’ `mkdir -p /.agents/caches/api-discovery/sources`, + then re-run the original `discover` query. +- **Alternative root** β†’ ask for the absolute path ``, then: + ```bash + mkdir -p "/.agents/caches/api-discovery/sources" + printf '%s\n' "" \ + > .agents/scripts/api-discovery/.workspace-root + ``` + (the pointer file is gitignored). Then re-run `discover`. +- **Non-cached** β†’ record the choice in **per-developer auto-memory** + (project memory, type `feedback`), `name: api-discovery-cache-disabled`, + describing the user's choice and giving the "How to apply" rule: + *do not invoke `extract-sources` in this repo; for non-Spine deps + fall back to other investigation tools*. Then proceed with + sibling-first only. + +Check that memory at session start. If it exists, skip cache-touching +paths entirely. + +## Workflow + +1. **Always** call `discover` before reading library source. +2. Use the returned path with `Grep`/`Read`/`Glob` directly. Do **not** + `cd` into the directory β€” that adds path-prefix noise to tool calls + and makes line citations harder to read. +3. If stderr contains `STALE: ...`, the sibling on disk does not match + the version declared in `buildSrc`. Surface the warning AND offer + to refresh β€” see *Refreshing a stale sibling* below. +4. If the script exits `1`, report the failure with its stderr + message and stop. Do not try `unzip` as a workaround. + +## Refreshing a stale sibling + +The user keeps siblings cloned locally as the source of truth and +sometimes works across several siblings at once with a feature branch +checked out in each. So a `STALE` line has two possible meanings, and +they require different handling: + +- **Sibling is behind `master`/`main`.** A `git pull --ff-only` will + bring it up to date. +- **Sibling is on a feature branch.** This is *intentional* β€” the user + is staging changes across multiple subprojects. The local code is + the right code; **do not** pull. + +You cannot tell which case applies without inspecting the sibling. The +companion script `update-sibling` handles both safely: it pulls only +on the default branch with a clean tree and a tracked upstream, and +exits `0` without touching anything when on a feature branch. + +### Procedure + +When you see a `STALE: ...` line from `discover`: + +1. Surface the warning to the user. +2. Ask, in one short prompt: + > The sibling at `` is stale. Want me to try updating it? + > I'll only `git pull --ff-only` if it's on `master`/`main` with + > a clean working tree; if you have a feature branch checked out, + > I'll leave it as-is. +3. If the user agrees, run: + ```bash + .agents/scripts/api-discovery/update-sibling + ``` + `` is either the absolute path shown by + `discover` (preferred β€” unambiguous) or just the sibling repo name + (resolved under ``). +4. Read **stdout** to decide what to do next β€” it is a single stable + token, not free-form English: + - `pulled` β€” HEAD advanced. Re-run `discover` so the STALE warning + clears (or, more rarely, reports a different discrepancy). + - `up-to-date` β€” sibling was already at upstream tip. The STALE + warning is informational β€” the declared `buildSrc` version and + the sibling's `versionToPublish` simply disagree. Proceed + without re-running `discover`. + - `skipped-branch` β€” sibling is on a feature branch and was left + untouched. Use the local code as-is; proceed without re-running. + + stderr always carries the human-readable diagnostics; surface it + to the user, but do not parse it to drive control flow. +5. If the user declines, proceed without pulling. Do not ask again + for the same sibling in the same session unless the user revisits. + +### `update-sibling` exit codes + +Exit 0 is split into three outcomes by the **stdout token** β€” read +that, not the stderr text. + +| Code | stdout | Meaning | What you do | +|---|---|---|---| +| `0` | `pulled` | HEAD advanced to upstream tip. | Re-run `discover` so the STALE warning clears. | +| `0` | `up-to-date` | Already at upstream tip; nothing to do. | Proceed; surface the STALE warning to the user as informational. | +| `0` | `skipped-branch` | On a non-default branch; left untouched. | Use the local code as-is; proceed without re-running. | +| `1` | _(empty)_ | Sibling not on disk. | Report the error. | +| `2` | _(empty)_ | Not a git repository. | Report the error; do not retry. | +| `3` | _(empty)_ | Detached HEAD β€” refused. | Tell the user; do not retry. | +| `4` | _(empty)_ | Working tree dirty β€” refused. | Tell the user; do not retry. | +| `5` | _(empty)_ | No upstream tracking on default branch β€” refused. | Tell the user. | +| `6` | _(empty)_ | `git pull --ff-only` failed (divergence, network, etc.). | Surface the git error verbatim. | +| `64` | _(empty)_ | Usage error (no/too many arguments). | Fix the invocation; do not retry blindly. | + +Failure paths produce **empty stdout** so the agent can never misread +an error message as a result token. + +### "Don't ask me again" + +If the user says something like "stop offering" or "skip the prompt +this session", remember that for the rest of the conversation and do +not prompt on subsequent STALE warnings β€” just surface the warning +and move on. This is **per-session** state; do not write it to +auto-memory. + +## Anti-patterns + +Stop doing these β€” they are exactly what this skill exists to replace: + +- `find ~/.gradle/caches/modules-2/files-2.1/ -name '*-sources.jar'` +- `unzip -l ` to list classes +- `unzip -p path/in/jar` to read a file +- Any chain of `unzip` + `grep` against a Gradle-cache JAR + +If you find yourself wanting to do those, run `discover` instead. + +## Examples + +**Spine artifact, fresh sibling on disk:** + +```text +$ .agents/scripts/api-discovery/discover io.spine:spine-base +/Users//Projects/Spine/base-libraries/base +$ echo $? +0 +``` + +Tool calls then look like: + +- `Glob` pattern `**/*.kt`, path + `/Users//Projects/Spine/base-libraries/base`. +- `Grep` pattern `class Identifier`, path the same. + +**Spine artifact, stale sibling:** + +```text +$ .agents/scripts/api-discovery/discover io.spine.tools:validation-java +api-discovery: STALE: validation-java declared 2.0.0-SNAPSHOT.433 in Validation.kt but sibling publishes 2.0.0-SNAPSHOT.440 +api-discovery: sources at /Users//Projects/Spine/validation/java may differ from the published artifact +/Users//Projects/Spine/validation/java +``` + +Surface the `STALE` line, then offer to refresh β€” see *Refreshing a +stale sibling*. After the user agrees and the pull succeeds, re-run +`discover` and the warning clears. + +**Stale sibling, refresh on master:** + +```text +$ .agents/scripts/api-discovery/update-sibling /Users//Projects/Spine/validation +Updating abc1234..def5678 +Fast-forward + ... +api-discovery: /Users//Projects/Spine/validation pulled 'master': abc1234... -> def5678... +pulled +$ echo $? +0 +``` + +Stdout is `pulled` β€” re-run `discover` to clear the STALE warning. + +**Stale sibling, already at upstream tip:** + +```text +$ .agents/scripts/api-discovery/update-sibling /Users//Projects/Spine/validation +Already up to date. +api-discovery: /Users//Projects/Spine/validation already up-to-date on 'master' (def5678...) +up-to-date +$ echo $? +0 +``` + +Stdout is `up-to-date` β€” the sibling is fresh; the STALE warning +reflects a declared-version vs. `versionToPublish` discrepancy that +`git pull` cannot resolve. Surface it to the user as informational. + +**Stale sibling, feature branch (no-op):** + +```text +$ .agents/scripts/api-discovery/update-sibling /Users//Projects/Spine/validation +api-discovery: /Users//Projects/Spine/validation is on 'feature/new-rule' (not master/main); using local code as-is +skipped-branch +$ echo $? +0 +``` + +Stdout is `skipped-branch` β€” feature branch is intentional local +state. Use the code as-is. + +**Non-Spine artifact, first use (extraction):** + +```text +$ .agents/scripts/api-discovery/discover com.google.guava:guava:33.5.0-jre +api-discovery: extracted com.google.guava:guava:33.5.0-jre -> .../guava/33.5.0-jre +/Users//Projects/Spine/.agents/caches/api-discovery/sources/com.google.guava/guava/33.5.0-jre +``` + +Second call returns the same path with no stderr (already cached). + +**Unresolvable:** + +```text +$ .agents/scripts/api-discovery/discover io.spine:does-not-exist:9.9.9 +api-discovery: io.spine:does-not-exist:9.9.9 is not in the local Gradle cache +api-discovery: run './gradlew dependencies' (or rebuild) to fetch it, then retry +$ echo $? +1 +``` + +Report the failure verbatim; do not try `unzip` as a workaround. + +## Related + +- Implementation reference: `.agents/scripts/api-discovery/README.md`. +- Sibling refresh on STALE: `.agents/scripts/api-discovery/update-sibling`. +- Manual cache pruning: `.agents/scripts/api-discovery/clean-cache`. diff --git a/.agents/skills/bump-gradle/SKILL.md b/.agents/skills/bump-gradle/SKILL.md new file mode 100644 index 000000000..22f295786 --- /dev/null +++ b/.agents/skills/bump-gradle/SKILL.md @@ -0,0 +1,148 @@ +--- +name: bump-gradle +description: > + Update the Gradle wrapper version used by this repository. Use when asked to + upgrade Gradle, bump the Gradle wrapper, move the project to the latest + Gradle release from the official release notes, run the Gradle build, and + commit Gradle wrapper and dependency report changes separately. +--- + +# Bump Gradle + +Use the official Gradle release notes as the source of truth for both the +latest version and the wrapper update command: + +https://docs.gradle.org/current/release-notes.html#upgrade-instructions + +Always check that page at task time. Do not rely on remembered Gradle versions. + +## Commit authorization + +This skill is authorized to run `git commit` **up to two times** per +invocation, under these constraints: + +1. **Gradle wrapper commit.** Stage only the Gradle wrapper files + (`gradle/wrapper/gradle-wrapper.properties`, + `gradle/wrapper/gradle-wrapper.jar`, `gradlew`, `gradlew.bat`, plus + files directly required by the wrapper update). Subject: + `` Bump Gradle -> `GRADLE_VERSION` `` with the actual version + substituted. Skip if no wrapper-owned file changed. + +2. **Dependency-report commit** (separate from the wrapper commit). Stage + only generated dependency-report files (`docs/dependencies/pom.xml`, + `docs/dependencies/dependencies.md`). Subject: + `Update dependency reports`. Skip if the build did not regenerate + those files. + +No `git push`, `git tag`, `git rebase`, `git commit --amend`, or any other +history-writing operation. Those require a separate authorization +(`.agents/safety-rules.md` β†’ *Commits and history-writing*). Do not create +empty commits, and do not bundle unrelated changes into either commit. + +## Checklist + +1. Work from the target repository root. + + Confirm `./gradlew` and `gradle/wrapper/gradle-wrapper.properties` exist + before changing anything. Inspect `git status --short` and preserve unrelated + user changes. If Gradle wrapper files are already modified, inspect the diff + and continue only when those edits are part of the same requested Gradle + bump; otherwise ask before overwriting or staging them. + +2. Read the latest Gradle version from the release notes. + + Open the Upgrade instructions section at the URL above. Use the version in + the release heading and the wrapper command shown there. They should agree; + if they do not, stop and report the mismatch. + +3. Run the wrapper update command. + + Substitute the version from the release notes: + + ```bash + ./gradlew wrapper --gradle-version=GRADLE_VERSION && ./gradlew wrapper + ``` + + For example, if the release notes say Gradle `9.5.1`, run: + + ```bash + ./gradlew wrapper --gradle-version=9.5.1 && ./gradlew wrapper + ``` + +4. Run the build. + + ```bash + ./gradlew clean build + ``` + + If the wrapper update or build fails, do not commit partial changes. Report + the failing command and the relevant error output. + +5. Commit only Gradle-related files. + + Inspect `git status --short` and `git diff --name-only`. Stage only files + created or updated by the Gradle wrapper bump, normally: + + ```text + gradle/wrapper/gradle-wrapper.properties + gradle/wrapper/gradle-wrapper.jar + gradlew + gradlew.bat + ``` + + Include other Gradle-owned files only when they are directly required by the + wrapper update and are clearly part of the same change. Do not stage + dependency reports or unrelated build output in this commit. + + Commit with the exact subject, replacing `GRADLE_VERSION`: + + ```text + Bump Gradle -> `GRADLE_VERSION` + ``` + + Example: + + ```bash + git commit -m 'Bump Gradle -> `9.5.1`' + ``` + + If no Gradle-related files changed, do not create an empty commit; report + that the wrapper was already current after verification. + +6. Commit dependency reports separately when the build updates them. + + Stage only generated dependency report files. In repositories using this + config, the usual paths are: + + ```text + docs/dependencies/pom.xml + docs/dependencies/dependencies.md + ``` + + Include other changed files only when they are clearly generated dependency + reports from the build. Commit them separately with: + + ```text + Update dependency reports + ``` + +7. Verify the final branch state. + + Confirm the recent commit subjects and make sure no owned Gradle bump or + dependency report changes remain unstaged: + + ```bash + git log --format=%s -2 + git status --short + ``` + + Leave unrelated pre-existing user changes alone and mention them separately + in the final response. + +8. Ensure `version.gradle.kts` is bumped. + + Before this branch can be built or published locally, the project + version must be strictly greater than the version on the base ref. + Invoke `/version-bumped` β€” it is a no-op if a bump has already + happened earlier on the branch, and otherwise calls `/bump-version` + to perform the increment. diff --git a/.agents/skills/bump-gradle/agents/openai.yaml b/.agents/skills/bump-gradle/agents/openai.yaml new file mode 100644 index 000000000..6edf97877 --- /dev/null +++ b/.agents/skills/bump-gradle/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Bump Gradle" + short_description: "Update the Gradle wrapper safely" + default_prompt: "Use $bump-gradle to update this repository to the latest Gradle wrapper version from the official release notes, build, and split Gradle/report commits." diff --git a/.agents/skills/bump-version/SKILL.md b/.agents/skills/bump-version/SKILL.md new file mode 100644 index 000000000..3e1d3d659 --- /dev/null +++ b/.agents/skills/bump-version/SKILL.md @@ -0,0 +1,135 @@ +--- +name: bump-version +description: > + Bump the project version in `version.gradle.kts` following the Spine SDK + versioning policy. Use when starting a new branch, before opening a PR, or + when CI rejects a branch for a missing/insufficient version increment. Covers + locating the published version value, choosing the increment, committing the + bump, rebuilding reports, and resolving version conflicts. +--- + +# Bump the project version + +The authoritative policy is [Spine SDK Versioning][version-policy]. In this +skill's target repository, CI runs the `Version Guard` workflow, which invokes +`checkVersionIncrement` through `IncrementGuard`. The task fails if the current +project version already exists in the Maven repository. It does not compare git +branches or inspect commit subjects; the checks below are agent-side guardrails. + +## Commit authorization + +This skill is authorized to run `git commit` **exactly once** per invocation, +under these constraints: + +- Stage only `version.gradle.kts`. Any other modified files are out of scope + for this skill's commit and must remain unstaged. +- Use the exact subject `` Bump version -> `` `` (see step 4 of the + Checklist) with the actual new version value substituted. +- No `git push`, `git tag`, `git rebase`, `git commit --amend`, or any other + history-writing operation. Those require a separate authorization + (`.agents/safety-rules.md` β†’ *Commits and history-writing*). + +If the bump cannot be performed cleanly (no diff to commit, conflicting +staged files, build failures preceding the commit), report and stop β€” do not +create the commit. + +## Checklist + +1. Work from the target repository root. + + Confirm `version.gradle.kts` exists before editing. If it is absent, stop and + report that this skill does not apply to the current checkout. + + Inspect `git status --short` before changing files. Preserve unrelated user + changes and stage only the version/report files this workflow owns. + +2. Locate `version.gradle.kts` and update the value that feeds + `versionToPublish`. + + The published version may be a literal: + + ```kotlin + val versionToPublish: String by extra("2.0.0-SNAPSHOT.182") + ``` + + Or it may come from another variable: + + ```kotlin + val compilerVersion: String by extra("2.0.0-SNAPSHOT.043") + val versionToPublish by extra(compilerVersion) + ``` + + In the second case, update the source value (`compilerVersion` here), not + only the `versionToPublish` alias. + +3. Choose the increment. + + For the normal snapshot-line PR, increment the trailing snapshot number by + one: `2.0.0-SNAPSHOT.182` -> `2.0.0-SNAPSHOT.183`. Preserve existing + zero-padding: `2.0.0-SNAPSHOT.009` -> `2.0.0-SNAPSHOT.010`. + + For a breaking snapshot-line PR, advance to the next multiple of 10 that is + strictly greater than the current value: `.187` -> `.190`, and `.180` -> + `.190`. + + For release-line work, follow the [policy][version-policy]: urgent fixes bump `PATCH`; + feature work or significant fixes bump `MINOR` and reset `PATCH` to `0`. + +4. Commit only the `version.gradle.kts` change with this subject: + + ```text + Bump version -> `2.0.0-SNAPSHOT.183` + ``` + + Use the actual new version in the subject. Do not include unrelated files in + this commit. + +5. Run the build to verify the bump and regenerate reports: + + ```bash + ./gradlew clean build + ``` + + Repos using this config commonly finalize `generatePom` and + `mergeAllLicenseReports` after `build`, which updates + `docs/dependencies/pom.xml` and `docs/dependencies/dependencies.md` when + those reports are configured. + +6. If `docs/dependencies/pom.xml` or `docs/dependencies/dependencies.md` changed, + commit those generated files separately: + + ```text + Update dependency reports + ``` + + If the PR has the `License Reports` workflow, make sure the branch modifies + `docs/dependencies/pom.xml` and `docs/dependencies/dependencies.md`. + +7. Validate the branch state. + + ```bash + BASE=master + git fetch --quiet origin "$BASE" + RANGE="$(git merge-base HEAD origin/$BASE)..HEAD" + git log --format=%s "$RANGE" | grep '^Bump version ->' + git diff --name-only "$RANGE" -- version.gradle.kts | grep '^version.gradle.kts$' + ``` + + Use the actual merge target for `BASE` when it is not `master`. + Also confirm `git status --short` has no uncommitted changes created by the + version bump or report regeneration. + +## Conflict Rule + +When merging a base branch into a feature branch: + +- If the base branch version is lower, keep the feature branch version. +- If the base branch version is greater than or equal to the feature branch + version, set the feature branch version to `base + 1`, or apply the breaking + change rounding rule. + +Do not require a completely clean worktree if unrelated user changes are +present. Instead, make sure no uncommitted changes were created by the version +bump or report regeneration. + +[version-policy]: https://github.com/SpineEventEngine/documentation/wiki/Versioning diff --git a/.agents/skills/bump-version/agents/openai.yaml b/.agents/skills/bump-version/agents/openai.yaml new file mode 100644 index 000000000..12f6e4f9b --- /dev/null +++ b/.agents/skills/bump-version/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Bump Version" + short_description: "Bump Spine project versions safely" + default_prompt: "Use $bump-version to bump the project version in version.gradle.kts, commit the version change, rebuild dependency reports, and verify the branch." diff --git a/.agents/skills/check-links/SKILL.md b/.agents/skills/check-links/SKILL.md new file mode 100644 index 000000000..5571e13fd --- /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/dependency-audit/SKILL.md b/.agents/skills/dependency-audit/SKILL.md new file mode 100644 index 000000000..010c16bce --- /dev/null +++ b/.agents/skills/dependency-audit/SKILL.md @@ -0,0 +1,146 @@ +--- +name: dependency-audit +description: > + Audit changes to dependency declarations under + `buildSrc/src/main/kotlin/io/spine/dependency/` β€” catches accidental + version downgrades, BOM mismatches, missing deprecation markers when + artifacts are renamed or removed, copyright drift, and convention drift. + Use whenever a diff touches that directory, or when asked to "audit + this dependency bump". Read-only; does not run builds. +--- + +# Dependency audit (repo-specific) + +You are the dependency auditor for a Spine Event Engine repo. All managed +dependencies live under: + + buildSrc/src/main/kotlin/io/spine/dependency/ + +organized by sub-package: + +- `lib/` β€” third-party runtime libraries (Kotlin, Guava, Protobuf, gRPC, …). +- `local/` β€” Spine SDK artifacts (Base, CoreJvm, ModelCompiler, …). +- `test/` β€” testing libraries (JUnit, Kotest, AssertK, Truth, Jacoco, Kover). +- `build/` β€” static-analysis and build-time tools (Dokka, ErrorProne, Pmd, + CheckStyle, KSP, …). +- `kotlinx/` β€” Kotlin-ecosystem libraries (Coroutines, Serialization, + DateTime, AtomicFu). +- `boms/` β€” BOM declarations. + +Each file declares a Kotlin `object` extending `Dependency` or `DependencyWithBom` +(see `dependency/Dependency.kt`). The shape is: + + object Kotest { + const val version = "6.1.11" + const val group = "io.kotest" + const val assertions = "$group:kotest-assertions-core:$version" + // … + } + +## How to run an audit + +1. **Fetch the full diff once.** Default base is `origin/master`: + `git diff origin/master...HEAD -- 'buildSrc/src/main/kotlin/io/spine/dependency/**'` + (use `--staged` if the user is mid-commit, or a different base only if + the user names one). The unified diff already contains the old and new + lines you need for version-sanity and BOM checks β€” do not call `--stat` + first and then re-read each file. If the diff is empty, ask the user + which files to audit. + +2. **Lean on the diff; `Read` on demand.** Version, BOM, copyright, and + deprecation deltas are all visible in the unified diff. Only `Read` a + file when (a) it is newly added, or (b) a hunk references a + `version`/`group` constant defined outside the hunk and you need + surrounding context. **Budget:** if more than 5 files changed, do not + `Read` individual files β€” work from the diff and use targeted `Grep` + for cross-cutting questions. + +3. **Batch independent work into one turn.** Issue the version-sanity (A), + convention-drift (D), and cross-cutting (E) tool calls *in parallel* + within a single response. Collect every finding and emit the report + once β€” **do not stop at the first failure**. + +4. **Batch greps.** For deprecation/caller checks (C) and snapshot-pin + checks (A), build one ripgrep over the union of symbols instead of one + command per symbol. Examples: + - `rg -n '\b(name1|name2|name3)\b' --type kt` to find callers of any + removed `const val`. + - `rg -L 'Copyright \(c\) 2026' ` to flag every stale + header in one call. + - `rg -L '@Suppress\("unused", "ConstPropertyName"\)' ` + to flag missing object-level suppression in one call. + - `rg -n '(lib1:oldv1|lib2:oldv2)' --type kt --type gradle` β€” one + alternation across libraries, not one command per library. + +5. **Fast path for pure version bumps.** If every hunk only modifies an + existing `version` (or `bom`) string literal β€” no added/removed + `const val`, no new files, no renames β€” run only Checks A and D. + Skip B, C, and E entirely. This is the dominant `/dependency-update` + shape; do not waste tool calls re-validating naming or deprecation + discipline when nothing structural changed. + +## Checks + +### A. Version sanity +- **No silent downgrade.** Compare the old and new `version` value as semver. + A decrease (`2.0.0 -> 1.9.0`) or a snapshot regression (`-SNAPSHOT.183` -> + `.182`) is a Must-fix unless the commit message explicitly justifies it. +- **Snapshot vs. release consistency.** If `version` switches from a release + (`2.0.0`) to a snapshot (`2.0.1-SNAPSHOT.001`), confirm the consuming code + isn't pinned to the release elsewhere. Use the batched ripgrep recipe + in step 4 β€” one alternation across all switched libraries, not one + command per library. +- **BOM ↔ component agreement.** For objects extending `DependencyWithBom`, + check that `bom` references the same version as `version` (e.g. Kotlin's + `kotlin-bom:$runtimeVersion`). + +### B. Naming and structure +- **Object name matches the upstream library name** (PascalCase). New files + must follow the convention of neighbors (e.g. `lib/Foo.kt` declares + `object Foo`). +- **No type names in property names** (`fooList`, `barObject`) β€” this is in + `.agents/coding-guidelines.md`. +- **Module constants use `"$group::$version"`**, not hardcoded + Maven coordinates. Catch copy-paste like `"io.kotest:kotest-assertions-core:6.1.11"`. + +### C. Deprecation discipline +When an artifact is **renamed or removed**: +- The old `const val` must stay with `@Deprecated("…", ReplaceWith("…"))` + or `@Deprecated("…")` (see `Kotest.frameworkApi` and `Kotest.datatest` for + the established style). +- If the diff deletes one or more `const val`s outright, confirm no caller + is left behind. Use the batched ripgrep recipe in step 4 β€” one + alternation over all removed symbol names, not one `git grep` per + name. If any caller survives, this is a Must-fix. + +### D. Convention drift +- **Copyright header year.** Every changed file should have a current-year + copyright line. If a file was edited but its copyright says `2024`, flag it + (the user can run `/update-copyright` to fix). +- **GitHub URL comment.** New `lib/` and `kotlinx/` files conventionally + start with `// https://github.com//` above the object. + Recommend it if missing. +- **`@Suppress("unused", "ConstPropertyName")` on the object.** This is the + established style for constant-heavy declarations. + +### E. Cross-cutting checks +- **`local/` deps don't leak.** Spine SDK artifacts in `local/` should not be + declared in `lib/` or `test/` (and vice versa). +- **No mixing Groovy and Kotlin DSL.** All Gradle code in `buildSrc/` must be + `.kt` or `.gradle.kts`. Catch any `.gradle` file slipping in. + +## Output format + +Three sections, in this order: + +- **Must fix** β€” version downgrades, missing deprecation markers on removed + symbols, broken callers, BOM/version mismatches. +- **Should fix** β€” convention drift, missing deprecation `ReplaceWith`, + missing copyright update, missing URL comment, naming oddities. +- **Nits** β€” formatting, ordering, doc-comment polish. + +For each finding, cite the file and line, quote the offending lines, and +show the recommended fix. + +End with a one-line verdict: `APPROVE`, `APPROVE WITH CHANGES`, or +`REQUEST CHANGES`. diff --git a/.agents/skills/dependency-update/SKILL.md b/.agents/skills/dependency-update/SKILL.md new file mode 100644 index 000000000..7e70bc126 --- /dev/null +++ b/.agents/skills/dependency-update/SKILL.md @@ -0,0 +1,283 @@ +--- +name: dependency-update +description: > + Walk every dependency declaration under + `buildSrc/src/main/kotlin/io/spine/dependency/`, discover the latest accepted + version of each artifact from the URL hinted in its file (or from Maven + metadata if no URL is present), and update the `version` constant in place. + External dependency scopes accept only released versions; the `local` scope + also accepts snapshots and pre-releases published from sibling Spine repos. + Use when asked to refresh dependency versions, bump libraries, run a + dependency audit, or "see what's stale". +--- + +# Update dependencies + +## Goal + +Bring every dependency object under +`buildSrc/src/main/kotlin/io/spine/dependency/` to its latest accepted version. +For every scope except `local/`, that means the latest **released** version: +snapshots, release candidates, milestones, alpha/beta, EAP, and `-dev` builds +are **excluded**. + +`local/` is the deliberate exception. It holds Spine SDK dependencies published +from sibling Spine repositories, and it may move to newer snapshots or +pre-releases such as `2.0.0-SNAPSHOT.388` or `2.1.0-RC1`. + +The authoritative version source for each artifact is the web page already +referenced in its file. When the file has no URL, use the Maven metadata +fallback described below. For non-`local/` artifacts, a discovered Maven +Central URL is **added back to the file** as a line comment so the next run has +a hint. + +## Inputs + +- No arguments β†’ scan all of `buildSrc/src/main/kotlin/io/spine/dependency/`. +- One or more paths or sub-package names (`lib`, `local`, `test`, `build`, + `kotlinx`, `boms`) β†’ restrict the scan to those. +- `--dry-run` β†’ discover and report, but do not edit. + +## Pre-flight + +1. Run `git status --short`. If the worktree is dirty in files this skill will + touch, stop and ask the user. Otherwise preserve unrelated changes. +2. Confirm `buildSrc/src/main/kotlin/io/spine/dependency/` exists. +3. Note the current branch β€” every change this skill makes is a candidate for + a single `chore(deps): refresh external versions` commit at the end; the + skill itself does NOT commit. The user decides. + +## Per-file workflow + +For each `*.kt` file in scope: + +### 1. Parse the file + +A dependency file declares one or more Kotlin `object`s, typically extending +`Dependency` or `DependencyWithBom`. The shape is: + + object Kotest { + const val version = "6.1.11" + const val group = "io.kotest" + const val assertions = "$group:kotest-assertions-core:$version" + // … + } + +Extract: + +- `objectName` β€” the outer `object` identifier. +- `version` β€” the literal version string. Some files have **multiple** version + constants (`runtimeVersion`, `embeddedVersion`, `annotationsVersion`); treat + each separately. The one driving the artifact is typically `override val + version = …` or the `const val version = …` declared at the top. +- `group` β€” the Maven group. +- `module` artifact names β€” each `const val foo = "$group:foo:$version"` line + contributes one artifact name. Use the first one to query Maven Central if + needed for non-`local/` artifacts, or Spine SDK Maven repositories for + `local/` artifacts. +- `versionUrl` β€” a URL hint. Look in this order: + 1. Line comments above the object: `^//\s*(https?://\S+)`. + 2. KDoc `@see …` inside the object's KDoc. + 3. Plain `@see https?://…` inside the KDoc. + 4. If none: leave `versionUrl` empty and use the Maven metadata fallback + below. + +Skip files that contain only abstract base classes or helpers (`Dependency.kt`, +`DependencyWithBom.kt`, `BomsPlugin.kt`, anything without a concrete artifact +declaration). + +### 2. Find the latest accepted version + +The discovery rule depends on the URL shape. For files under +`dependency/local/`, check the Spine SDK Maven metadata before GitHub, even +when the file has a GitHub URL; snapshots are usually visible in Maven +metadata, not in GitHub's latest-release redirect. + +**A. GitHub repository URL** (`https://github.com//`): + +- Outside `local/`, resolve + `https://github.com///releases/latest`. GitHub redirects to the + latest non-prerelease tag. Read the redirected location or the rendered HTML + to extract the tag. +- In `local/`, do **not** rely on `/releases/latest`, because it hides + pre-releases. Use GitHub releases and tags only after checking Spine SDK + Maven metadata. When you do use GitHub, include pre-release entries and keep + version-like tags that match the artifact. +- Tags often have a `v` prefix. Strip it. +- If the repo publishes per-component tags (e.g. + `kotlinx-coroutines-1.10.2`), prefer the tag whose name matches the + artifact's module identifier. Otherwise take the topmost release. + +**B. Maven Central artifact URL** +(`https://search.maven.org/artifact//` or +`https://repo1.maven.org/maven2///`): + +- Hit Maven Central's REST API: + `https://search.maven.org/solrsearch/select?q=g:+AND+a:&rows=20&core=gav` +- Outside `local/`, filter the `response.docs[].v` values by the pre-release + rule (below). +- In `local/`, keep snapshots and pre-releases in the candidate list. +- Take the highest by semver comparison. + +**C. Spine SDK Maven repositories for `local/` artifacts**: + +- For files under `dependency/local/`, query Maven metadata in the current + Spine SDK Artifact Registry repositories before falling back elsewhere: + - `https://europe-maven.pkg.dev/spine-event-engine/releases` + - `https://europe-maven.pkg.dev/spine-event-engine/snapshots` +- Build the metadata URL as + `///maven-metadata.xml`, where `groupPath` is the + Maven group after first resolving symbolic aliases used in dependency files + (for example, `Spine.group` -> `io.spine` and `Spine.toolsGroup` -> + `io.spine.tools`) and then replacing dots with slashes. +- Read `...` entries. For `local/`, do not + reject `SNAPSHOT`, RC, milestone, alpha, beta, EAP, pre, or dev versions. +- If both release and snapshot repositories have candidates, compare all of + them together and take the highest version. + +**D. Project homepage** (e.g. `https://kotest.io/`, `https://junit.org/`, +`https://www.detekt.dev/`): + +- Try to find a "latest release" or "download" link on the page. If the page + is a thin landing page with no usable version data, fall through to E. + +**E. No URL or unusable URL β€” Maven metadata fallback**: + +- Outside `local/`, query Maven Central as in B using the file's `group` and + the first module artifact name (the part after `$group:`). +- In `local/`, query the Spine SDK Maven metadata first. Use Maven Central only + if the artifact is absent from those repositories. +- If a non-`local/` Maven Central fallback query returns results, **also insert + a line comment** + `// https://search.maven.org/artifact//` above the object + declaration (after any existing copyright header). This back-fills the URL + hint for next time. Match the existing comment style (one line, no trailing + punctuation). +- If all fallback queries have no result, leave the file untouched and add it + to the `Manual review` section of the final report. + +### 3. Filter pre-releases outside `local/` + +Apply this filter only to files outside `dependency/local/`. + +For `local/` files, snapshots and pre-releases are accepted candidates. Do not +put them in `Filtered pre-releases`; put them in the `local/` confirmation +section of the final report instead. + +Reject any version string matching, case-insensitively: + + -SNAPSHOT$ + -RC[\d\-.]*$ e.g. -RC1, -RC.2 + -M\d+$ e.g. -M3 + -alpha[\d\-.]*$ + -beta[\d\-.]*$ + -EAP[\d\-.]*$ + -pre[\d\-.]*$ + -dev[\d\-.]*$ + \.Beta\d*$ Spring-style trailing tokens + \.Alpha\d*$ + \.RC\d*$ + \.M\d+$ + +Apply the regex to the **suffix after the numeric version**. The version +`2.0.0-SNAPSHOT.182` is a snapshot and must be rejected as a target outside +`local/`, but it is valid for `local/` dependency objects. This skill only +edits dependency files, never `version.gradle.kts` (that belongs to the +`bump-version` skill). + +### 4. Compare versions + +Use semver comparison: + +- Split on `.` and `-`. +- Numeric segments compare numerically; non-numeric segments compare + lexicographically. +- A version without any pre-release suffix is greater than one with the same + numeric prefix but a pre-release suffix. + +Only update when `latest > current`. Equal or lower β†’ no change. + +### 5. Apply the edit + +- Replace the `version` literal with the new value. Use a precise replacement + anchored on the full line (`const val version = ""` β†’ + `const val version = ""`). Do not blindly replace the version string, + because the same string can appear in module URLs constructed via + interpolation (`"$group:…:$version"`) β€” those will pick up the new value + automatically. +- If the file uses a renamed version constant (`runtimeVersion`, + `compilerVersion`, etc.) that feeds `override val version = compilerVersion`, + update the **source** constant, not the alias. +- For `DependencyWithBom` objects, verify the `bom` line still resolves + correctly. The conventional shape is + `override val bom = "$group:-bom:$version"`, in which case no + separate edit is needed. If the BOM version is hard-coded, update it too. +- Preserve indentation, comment style, and surrounding blank lines exactly. + +### 6. Watch for `local/` artifacts + +`local/` holds Spine SDK dependencies (Base, CoreJvm, ModelCompiler, …) that +are published from sibling Spine repos. This scope accepts snapshots and +pre-releases because these artifacts often advance through internal snapshot +builds before a stable SDK release. + +Still **flag every `local/` update in the report**, and note whether the target +is a release, snapshot, or pre-release. The user can then decide whether to +bump the SDK in lockstep with the rest of the project. Spine SDK artifacts +often need to move together; one-off bumps can cause runtime ABI mismatches. + +## Report + +When the run completes, emit a Markdown report with these sections: + +- **Updated** β€” table of `file | objectName | old β†’ new | source URL`. +- **Already current** β€” file/object pairs whose version was already the + newest accepted version. +- **Skipped (no URL, metadata empty)** β€” manual review needed. +- **Filtered pre-releases** β€” newer versions found but rejected because they + were RC/SNAPSHOT/alpha/etc. Applies only outside `local/`. +- **`local/` bumps to confirm** β€” every `local/` change called out separately, + including snapshot and pre-release targets. + +End with the suggested next steps: + +1. Review the diff (`git diff buildSrc/src/main/kotlin/io/spine/dependency/`). +2. Invoke `/version-bumped`. Every feature branch must advance + `version.gradle.kts` strictly above the base before any + `./gradlew build` (which may transitively `publishToMavenLocal`). The + skill is a no-op when a bump already happened earlier on the branch + and otherwise calls `/bump-version` to perform the increment. +3. Run `./gradlew build` (or `./gradlew clean build` if `.proto` files + participate). +4. Commit. Match the shape of the actual change: + - Single `local/` bump (most common): `` Bump Spine Base -> `2.0.0-SNAPSHOT.190` `` + - Coordinated external set: `Bump Protobuf and gRPC` (one commit; + mention both). + - Bulk external refresh (rare): `Refresh external dependencies`. + +## Safety + +- Do not commit. Do not push. Editing files is the limit of this skill's + authority. +- Never edit `version.gradle.kts` β€” that's the `bump-version` skill's + responsibility. +- Never auto-resolve a Maven Central query that returns multiple matching + artifacts with different groups (e.g. a library that exists under both + `io.netty` and `io.netty.incubator`). Ask the user. +- If a discovered "latest" version is more than one **major** ahead of the + current value (e.g. `1.x` β†’ `3.x`), flag it as a major bump in the report + and apply the edit only if the user confirms, or only when running + non-interactively with `--include-majors`. Major bumps frequently break + ABI. + +## Failure modes to expect + +- **GitHub rate limit** on the unauthenticated REST API. The `/releases/latest` + HTML page does not require auth and is the preferred fallback. +- **Per-component tags** in a monorepo. Match by artifact name, don't take the + topmost tag blindly. +- **Repositories that publish to JCenter only** β€” JCenter is sunset; if Maven + Central is empty, the dependency may need migration. Flag it. +- **Vendor-specific version schemes** (e.g. dates: `2025.10.01`) β€” the + semver comparator above will still order these correctly; just don't + mis-classify them as pre-releases. diff --git a/.agents/skills/dependency-update/agents/openai.yaml b/.agents/skills/dependency-update/agents/openai.yaml new file mode 100644 index 000000000..a61198d32 --- /dev/null +++ b/.agents/skills/dependency-update/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Dependency Update" + short_description: "Refresh dependency versions, allowing snapshots only for local Spine SDK artifacts" + default_prompt: "Use $dependency-update to walk every dependency object under buildSrc/src/main/kotlin/io/spine/dependency/, find the latest accepted version for each, and update the version constants in place. External scopes use released non-snapshot versions only; dependency/local/ may use snapshots and pre-releases from sibling Spine repos. Use the URL referenced in each file as the source of truth; fall back to Maven metadata and back-fill missing hints when useful." diff --git a/.agents/skills/java-to-kotlin/SKILL.md b/.agents/skills/java-to-kotlin/SKILL.md new file mode 100644 index 000000000..b9835f8f7 --- /dev/null +++ b/.agents/skills/java-to-kotlin/SKILL.md @@ -0,0 +1,59 @@ +--- +name: java-to-kotlin +description: > + Convert Java code to Kotlin, including Java API comments from Javadoc to KDoc. + Use when asked to migrate Java files, classes, methods, nullability semantics, + or common Java patterns into idiomatic Kotlin while preserving behavior. +--- + +# πŸͺ„ Converting Java code to Kotlin + +* Java code API comments are Javadoc format. +* Kotlin code API comments are in KDoc format. + +## Javadoc to KDoc conversion + +* The wording of original Javadoc comments must be preserved. + +## Treating nullability + +* Use nullable Kotlin type only if the type in Java is annotated as `@Nullable`. + +## Efficient Conversion Workflow + +* First, analyze the entire Java file structure before beginning conversion to understand dependencies and class relationships. +* Convert Java code to Kotlin systematically: imports first, followed by class definitions, methods, and finally expressions. +* Preserve all existing functionality and behavior during conversion. +* Maintain original code structure and organization to ensure readability. + +## Common Java to Kotlin Patterns + +* Convert Java getters/setters to Kotlin properties with appropriate visibility modifiers. +* Transform Java static methods to companion object functions or top-level functions as appropriate. +* Replace Java anonymous classes with Kotlin lambda expressions when possible. +* Convert Java interfaces with default methods to Kotlin interfaces with implementations. +* Transform Java builders to Kotlin DSL patterns when appropriate. + +## Error Prevention + +* Pay special attention to Java's checked exceptions versus Kotlin's unchecked exceptions. +* Be cautious with Java wildcards (`? extends`, `? super`) conversion to Kotlin's `out` and `in` type parameters. +* Ensure proper handling of Java static initialization blocks in Kotlin companion objects. +* Verify that Java overloaded methods convert correctly with appropriate default parameter values in Kotlin. +* Remember that Kotlin has smart casts which can eliminate explicit type casting needed in Java. + +## Documentation Conversion + +* Convert `@param` to `@param` with the same description. +* Convert `@return` to `@return` with the same description. +* Convert `@throws` to `@throws` with the same description. +* Convert `{@link}` to `[name][fully.qualified.Name]` format. +* Convert `{@code}` to inline code with backticks (`). + +## Final step: ensure the version is bumped + +After the conversion is verified, invoke `/version-bumped` so the branch +carries a strictly greater `version.gradle.kts` than the base ref before +any `./gradlew build` (which may transitively `publishToMavenLocal` and +overwrite the previously published snapshot consumer repos depend on). +The skill is a no-op when a bump already happened earlier on the branch. diff --git a/.agents/skills/java-to-kotlin/agents/openai.yaml b/.agents/skills/java-to-kotlin/agents/openai.yaml new file mode 100644 index 000000000..252920fed --- /dev/null +++ b/.agents/skills/java-to-kotlin/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Java to Kotlin" + short_description: "Convert Java code to idiomatic Kotlin" + default_prompt: "Use $java-to-kotlin to convert Java code to Kotlin while preserving behavior, nullability, and API documentation wording." diff --git a/.agents/skills/kotlin-review/SKILL.md b/.agents/skills/kotlin-review/SKILL.md new file mode 100644 index 000000000..c55c8c49c --- /dev/null +++ b/.agents/skills/kotlin-review/SKILL.md @@ -0,0 +1,62 @@ +--- +name: kotlin-review +description: > + Review Kotlin (and Java) changes in this repo against the Spine coding + guidelines, safety rules, and testing policy. Use after any non-trivial + code edit, before opening a PR, or when asked for a code review. + Read-only; does not run builds. +--- + +# Kotlin code review (repo-specific) + +You are the Kotlin reviewer for this repository. The authoritative standards +live in `.agents/`: + +- `.agents/coding-guidelines.md` β€” Kotlin idioms, formatting, what to prefer/avoid. +- `.agents/safety-rules.md` and `.agents/advanced-safety-rules.md` β€” hard constraints + (no reflection without approval, no analytics/telemetry, no blocking calls in + coroutines, no auto-updating external dependencies). +- `.agents/testing.md` β€” Kotest assertions preferred, stubs not mocks. +- `.agents/project-structure-expectations.md` β€” module/source-set layout. +- `.agents/version-policy.md` β€” version bumps are required only when the + repository has a root `version.gradle.kts`. + +## Review procedure + +1. Read the diff. Use `git diff --staged` or `git diff ...HEAD` depending on + what the user describes. Do NOT review the full repo β€” only what changed. +2. Read each affected file fully, not just the diff hunks. Smart casts, + nullability, and idiomatic refactors require surrounding context. +3. Check against `.agents/coding-guidelines.md`: + - Kotlin idioms (extension functions, `when`, smart casts, data/sealed classes). + - Immutability by default. + - No `!!` without justification. + - No type names in variable names. + - No string duplication β€” use companion-object constants. + - No mixing Groovy/Kotlin DSL in build logic. + - No double empty lines (collapse to a single empty line); no trailing whitespace. +4. Check safety rules: reflection, telemetry, blocking-in-coroutines, dependency + bumps that weren't requested. +5. Check tests: every functional change should have tests using Kotest assertions + and stubs (not mocks). +6. Check the version gate: + - If the repository has a root `version.gradle.kts`, confirm it was + incremented when the change is user-visible. + - If root `version.gradle.kts` is absent at both the base ref and `HEAD`, + the version check is not applicable. Do not report a missing version bump + or ask for the file to be created. + +## Output format + +Return three sections, in this order: + +- **Must fix** β€” violations of safety rules, broken builds, missing version + bump when the version gate applies, missing tests for functional changes. +- **Should fix** β€” coding-guideline violations and clearer idiomatic alternatives. + Cite the specific guideline. +- **Nits** β€” style and naming suggestions. + +For each item, quote the file and line, show the current code, and show the +recommended replacement. If there's nothing in a section, write "None." + +End with a one-line verdict: `APPROVE`, `APPROVE WITH CHANGES`, or `REQUEST CHANGES`. diff --git a/.agents/skills/move-files/SKILL.md b/.agents/skills/move-files/SKILL.md new file mode 100644 index 000000000..b92b05d3f --- /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 000000000..ba90a9f8f --- /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 000000000..2c81dfde0 --- /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 000000000..d936fa28a --- /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 000000000..6afc4c7cf --- /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 000000000..246dd647f --- /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 000000000..2dbf8bbc4 --- /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 000000000..8770b3275 --- /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 000000000..86ca53df0 --- /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 000000000..f050a5b79 --- /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 000000000..44eaa4e24 --- /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 000000000..f405b71e1 --- /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 000000000..fdbd9b6a0 --- /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 000000000..5517516f5 --- /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 000000000..325f52cf5 --- /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/api-discovery.md b/.agents/tasks/api-discovery.md new file mode 100644 index 000000000..1671f5eac --- /dev/null +++ b/.agents/tasks/api-discovery.md @@ -0,0 +1,156 @@ +--- +slug: api-discovery +branch: improve-api-discovery +owner: claude +status: in-review +started: 2026-05-21 +--- + +## Goal + +Make Spine API discovery fast and token-efficient by directing agents +to read library sources from local sibling clones first, and from a +one-time-extracted sources-JAR cache otherwise β€” never via repeated +`unzip` against Gradle-cache JARs. + +## Context + +Investigation transcripts (see +`~/Desktop/gradle-caches-scanning-by-claude.png`) show agents running +dozens of `find ~/.gradle/caches` + `unzip -l` + `unzip -p` calls per +query. Each call decompresses the JAR; token usage is dominated by +path noise and JAR listings. The user keeps every Spine repo cloned +as a sibling under `/Users/sanders/Projects/Spine/`, so the raw +sources are already on disk for ~14 of the 16 Spine local deps and +just need to be reached directly. + +Detailed design in `~/.claude/plans/mellow-juggling-yeti.md`. + +## Plan + +- [x] Draft plan + design review (Plan agent) +- [x] Write task file +- [x] Implement `lib/common.sh` (shared bash helpers) +- [x] Implement `discover` (main entry) +- [x] Implement `extract-sources` (one-shot JAR extraction, race-safe) +- [x] Implement `clean-cache` (manual pruning) +- [x] Write `README.md` + `.gitignore` for `.agents/scripts/api-discovery/` +- [x] Write `SKILL.md` for `.agents/skills/api-discovery/` +- [x] Add one bullet to `CLAUDE.md` under Workflow Rules +- [x] Smoke tests (#1–#7 from plan) +- [x] Code-review fixes (six findings, see Log) +- [x] **Follow-up:** `update-sibling` script + skill workflow for STALE +- [ ] Human review / merge; delete file on merge to master + +## Follow-up: sibling auto-update on STALE + +Originally deferred under "Out of scope" in the plan. User asked for +it: when STALE fires, the agent should offer to refresh the sibling +clone so api-discovery returns up-to-date sources. Constraints from +the user's reply: + +- Pull only when the sibling is on its default branch (`master` or + `main`) β€” they explicitly use checked-out feature branches as a + staging area for "advancing multiple subprojects at the same time", + so a feature branch is *intentional* local state and must be left + alone. +- The action must be confirmed by the user, never autonomous. + +Design: + +- New script `update-sibling`: + - Resolves a sibling by bare name (under ``) or by + absolute path. + - Branch ∈ {`master`,`main`} + clean tree + tracked upstream + β†’ `git pull --ff-only`. + - Any other branch β†’ no-op, exit 0 with "using local code as-is". + - Detached HEAD / dirty tree / no upstream β†’ distinct exit codes + (3 / 4 / 5) with descriptive stderr. + - Pull failure β†’ exit 6. + - Never switches branches, never `--rebase`, never `--force`, never + fetches a branch the user does not track. +- SKILL.md gains a "When STALE fires" section: surface the warning, + ask the user, run `update-sibling` on consent, re-run `discover` + if a pull happened. +- README.md documents the new script + adds it to the Layout table. + +## Log + +- 2026-05-21 β€” drafted plan; Plan-agent reviewed and pushed back on + over-engineering (manifest, sibling repo, exit codes). Adopted + simplifications. +- 2026-05-21 β€” cache location: `/.agents/caches/api-discovery/` + with first-use bootstrap prompt (approve / alt root / non-cached). + Scripts and skill live in the consumer repo's `.agents/`. +- 2026-05-21 β€” implementation begins. +- 2026-05-21 β€” implementation complete. All 7 smoke tests pass. + Resolved all 24 Spine artifacts end-to-end (Base/Change/Logging KMP/ + multi-module ProtoData/Validation/Tool-base/Mc-java). Extension-cache + path tested with Jackson and Guava β€” concurrent extractions race + safely on atomic `mv` with no `.tmp.*` leftovers. `STALE` warning + fires for validation-java (declared .433 vs sibling .440). KMP + source sets (`src/commonMain`, `src/jvmMain`, …) recognized in + addition to plain `src/main`. Status flipped to `in-review` for + human merge; task file will be deleted on merge to `master`. +- 2026-05-21 β€” added `update-sibling` follow-up: a guarded + `git pull --ff-only` for stale Spine siblings. Branch ∈ + {`master`,`main`} + clean tracked tree + tracked upstream β†’ pull; + any other branch β†’ no-op exit 0 ("intentional local state"); + detached / dirty / no-upstream β†’ distinct refusals (exit 3/4/5); + pull failure β†’ exit 6. Uses `--untracked-files=no` so build + artifacts and editor scratch don't block pulls. SKILL.md and + scripts/README.md document the workflow; the agent must ask the + user before invoking. Verified all 8 paths: successful FF on a + synthetic master + upstream, no-op on `validation` (`address-issues` + branch), exit 4 on `base-libraries` (dirty), exit 1 on missing, + exit 2 on non-repo, exit 3 on detached HEAD, exit 5 on no upstream, + exit 1 on missing args. The `main` default branch is also accepted. +- 2026-05-21 β€” `update-sibling` code-review pass applied five fixes: + (a) README exit-0 row was missing the `already up-to-date` outcome. + (b) `usage()` exited `EX_FAIL` (1), conflating "bad invocation" with + "sibling not on disk". Added `EX_USAGE=64` (BSD `sysexits(3)`) + and routed `usage()` to it; `sibling not on disk` keeps exit 1. + (c) Reworded the dirty-tree guard comment: untracked files don't + block FF on their own, but a genuine overwrite conflict (upstream + adds a path that exists untracked locally) still surfaces via + git's own check as `EX_PULL_FAILED`. Original "no effect on + semantics" wording was misleading. + (d) Exit 0 conflated three outcomes (pulled / up-to-date / + skipped-branch) and the skill had to parse free-form English log + lines to tell them apart. Now each success path emits a single + stable stdout token (`pulled`, `up-to-date`, `skipped-branch`); + failure paths emit empty stdout. Stderr keeps the human text. + (e) `SKILL.md` rewritten around the token contract: the exit-code + table splits exit 0 into three token-keyed sub-rows, procedure + step 4 branches on the token (not stderr), and a new + `up-to-date` example was added. `README.md` exit-code table got + the same split plus an `EX_USAGE=64` row. + Smoke-tested all eight paths end-to-end: synthetic upstream FF emits + `pulled`, re-run on the same clone emits `up-to-date`, `validation` + on `address-issues` emits `skipped-branch`, `base-libraries` (dirty) + exits 4, missing path exits 1, non-repo exits 2, no-args/too-many + exit 64. All failure paths produce empty stdout β€” agent can never + misread an error message as a result token. +- 2026-05-21 β€” earlier code-review pass applied six fixes: + (1) `extract-sources`: pre-test `[ -e "$target" ]` plus post-mv + nested-debris cleanup. Previous version was unsafe because + `mv tmp target` into an existing directory silently moves tmp + INSIDE target on macOS/Linux instead of failing. + (2) `discover`: replaced `target=$(...); status=$?` with + `target=$(...) || exit $?` so `set -e` cannot terminate + between assignment and status check. + (3) `clean-cache`: added `prune_empty_parents()` and call it on + both removal and "no entries match" paths (skipped under + `--dry-run`). Empty `//` dirs are now + reclaimed. + (4) `read_declared_version`: anchored regex at line start (with + optional access modifier) to avoid matching `const val version` + strings in KDoc / comments / nested code. + (5) Removed dead `find_dep_file_for_artifact` (callers all use + `find_local_dep_file_for_artifact`). + (6) `find_local_dep_file_for_artifact`: validate artifact name + against `[A-Za-z0-9._-]` and ERE-escape it via new + `escape_ere()` before grep, blocking regex-metachar injection. + All seven smoke tests still pass; race-safety verified by parallel + extractions of `guava-testlib:33.5.0-jre` (both exit 0, no `.tmp.*` + remnants, no nested `target/v.tmp.PID/` debris). diff --git a/.agents/tasks/prohibit-automatic-commits.md b/.agents/tasks/prohibit-automatic-commits.md new file mode 100644 index 000000000..ff067c505 --- /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 000000000..71f0c4fb9 --- /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 000000000..02672e2c8 --- /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/testing.md b/.agents/testing.md new file mode 100644 index 000000000..f81bdbf3d --- /dev/null +++ b/.agents/testing.md @@ -0,0 +1,8 @@ +# πŸ§ͺ Testing + +- Do not use mocks, use stubs. +- Prefer [Kotest assertions][kotest-assertions] over assertions from JUnit or Google Truth. +- Generate unit tests for APIs (handles edge cases/scenarios). +- Supply scaffolds for typical Kotlin patterns (`when`, sealed classes). + +[kotest-assertions]: https://kotest.io/docs/assertions/assertions.html diff --git a/.agents/version-policy.md b/.agents/version-policy.md new file mode 100644 index 000000000..3e8abd549 --- /dev/null +++ b/.agents/version-policy.md @@ -0,0 +1,19 @@ +# Version policy + +When a repository has `version.gradle.kts` at the project root, it follows the +[Spine SDK Versioning policy][wiki-versioning]. The version is kept in that +file and follows [Semantic Versioning 2.0.0][semver] with Spine-specific +extensions (snapshot `NUMBER`, patch, and flavor suffixes). + +For repositories with root `version.gradle.kts`, PRs without a version bump +fail CI. Repositories without that file are not versioned Gradle Build Tools +projects; their version check is not applicable, and agents must not create +`version.gradle.kts` just to satisfy `/pre-pr`. + +For the bump procedure in repositories that have root `version.gradle.kts` β€” +version-number selection, the commit-message convention, the rebuild, +dependency-report updates, and conflict resolution β€” use the +[`bump-version`](skills/bump-version/SKILL.md) skill. + +[semver]: https://semver.org/ +[wiki-versioning]: https://github.com/SpineEventEngine/documentation/wiki/Versioning 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 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 KDoc. +argument-hint: "" +allowed-tools: Read, Edit, Write, Bash(./gradlew:*), Bash(git status:*), Grep, Glob +--- + +Follow the `java-to-kotlin` skill exactly: + +- Skill: `.agents/skills/java-to-kotlin/SKILL.md` +- Target: $ARGUMENTS +- Preserve behavior. Convert Javadoc to KDoc, `@Nullable` to nullable Kotlin types, getters/setters to properties, static methods to companion objects or top-level functions. +- After each file, run `./gradlew compileKotlin` (or the relevant module's compile task) to verify. +- Honor `.agents/coding-guidelines.md` for Kotlin idioms. diff --git a/.claude/commands/move-files.md b/.claude/commands/move-files.md new file mode 100644 index 000000000..25885f9d7 --- /dev/null +++ b/.claude/commands/move-files.md @@ -0,0 +1,12 @@ +--- +description: Move or rename files/directories, updating all references and build metadata. +argument-hint: " " +allowed-tools: Read, Edit, Bash(git mv:*), Bash(git status:*), Bash(git ls-files:*), Grep, Glob +--- + +Follow the `move-files` skill exactly: + +- Skill: `.agents/skills/move-files/SKILL.md` +- Operation: $ARGUMENTS +- Preflight (run `git status --short`, classify scope) -> Search for all old identifiers -> Move with `git mv` -> Repair references (imports, build metadata, docs) -> Verify. +- Report: Moved[], UpdatedRefs[], Verification[], Risks[]. diff --git a/.claude/commands/pre-pr.md b/.claude/commands/pre-pr.md new file mode 100644 index 000000000..24499cc51 --- /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 000000000..f8043f0ea --- /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/run-build.md b/.claude/commands/run-build.md new file mode 100644 index 000000000..8a8d84ca0 --- /dev/null +++ b/.claude/commands/run-build.md @@ -0,0 +1,12 @@ +--- +description: Build the project the right way based on what changed (proto vs. Kotlin/Java vs. docs). +allowed-tools: Bash(./gradlew:*), Bash(git status:*), Bash(git diff:*) +--- + +Decide which build to run by looking at `git status --short` and `git diff --name-only`: + +- If any `.proto` files changed: `./gradlew clean build` +- Else if Kotlin or Java source changed: `./gradlew build` +- Else if only docs/comments changed (KDoc / Javadoc / Markdown): `./gradlew dokka`. Tests are NOT required for doc-only changes. + +Report the chosen command and its result. See `.agents/running-builds.md`. diff --git a/.claude/commands/update-copyright.md b/.claude/commands/update-copyright.md new file mode 100644 index 000000000..076fb6133 --- /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 000000000..b9b9a742b --- /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 000000000..f7bbfb98f --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,96 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "allow": [ + "Bash(./gradlew:*)", + "Bash(./config/gradlew:*)", + "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 push:*)", + "Bash(git rev-parse:*)", + "Bash(git ls-files:*)", + "Bash(git mv:*)", + "Bash(git submodule status:*)", + "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 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:*)", + "Bash(./gradlew publish:*)", + "Bash(./gradlew uploadArtifacts:*)", + "Bash(./gradlew clean:*)" + ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.agents/scripts/protect-version-file.sh" + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.agents/scripts/pre-pr-gate.sh" + }, + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.agents/scripts/publish-version-gate.sh" + } + ] + } + ], + "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 000000000..2b7a412b8 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..81c8d500c --- /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/build-on-ubuntu.yml b/.github/workflows/build-on-ubuntu.yml new file mode 100644 index 000000000..cd6b93714 --- /dev/null +++ b/.github/workflows/build-on-ubuntu.yml @@ -0,0 +1,38 @@ +name: Ubuntu CI + +on: push + +jobs: + build: + name: Build on Ubuntu + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + submodules: 'true' + + - uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: zulu + cache: gradle + + - name: Build project and run tests + shell: bash + run: ./gradlew build --stacktrace + + # See: https://github.com/marketplace/actions/junit-report-action + - name: Publish Test Report + uses: mikepenz/action-junit-report@v4.0.3 + if: always() # always run even if the previous step fails + with: + report_paths: '**/build/test-results/**/TEST-*.xml' + require_tests: true # will fail workflow if test reports not found + + - name: Upload code coverage report + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + verbose: true diff --git a/.github/workflows/build-on-windows.yml b/.github/workflows/build-on-windows.yml new file mode 100644 index 000000000..91a0bfef3 --- /dev/null +++ b/.github/workflows/build-on-windows.yml @@ -0,0 +1,36 @@ +name: Windows CI + +on: pull_request + +jobs: + build: + runs-on: windows-latest + name: Build on Windows + + steps: + - uses: actions/checkout@v4 + with: + submodules: 'true' + + - uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: zulu + cache: gradle + + # See: https://github.com/al-cheb/configure-pagefile-action + - name: Configure Pagefile + uses: al-cheb/configure-pagefile-action@v1.3 + + - name: Build project and run tests + shell: cmd + # For the reason on `--no-daemon` see https://github.com/actions/cache/issues/454 + run: gradlew.bat build --stacktrace --no-daemon + + # See: https://github.com/marketplace/actions/junit-report-action + - name: Publish Test Report + uses: mikepenz/action-junit-report@v4.0.3 + if: always() # always run even if the previous step fails + with: + report_paths: '**/build/test-results/**/TEST-*.xml' + require_tests: true # will fail workflow if test reports not found diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml new file mode 100644 index 000000000..7a51f8f1a --- /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/.github/workflows/ensure-reports-updated.yml b/.github/workflows/ensure-reports-updated.yml new file mode 100644 index 000000000..315cd202b --- /dev/null +++ b/.github/workflows/ensure-reports-updated.yml @@ -0,0 +1,25 @@ +# Ensures that the license report files were modified in this PR. + +name: License Reports + +on: + pull_request: + branches: + - '**' + +jobs: + check: + name: Ensure license reports are updated + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + # Configure the checkout of all branches so that it is possible to run the comparison. + fetch-depth: 0 + # Check out the `config` submodule to fetch the required script file. + submodules: true + + - name: Check that dependency report files are modified + shell: bash + run: chmod +x ./config/scripts/ensure-reports-updated.sh && ./config/scripts/ensure-reports-updated.sh diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml new file mode 100644 index 000000000..50eb05eb1 --- /dev/null +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -0,0 +1,19 @@ +name: Gradle Wrapper validation +on: + push: + branches: + - main + pull_request: + branches: + - '**' + +jobs: + validation: + name: Validate Gradle Wrapper + runs-on: ubuntu-latest + steps: + - name: Checkout latest code + uses: actions/checkout@v4 + + - name: Validate Gradle Wrapper + uses: gradle/actions/wrapper-validation@v4 diff --git a/.github/workflows/increment-guard.yml b/.github/workflows/increment-guard.yml new file mode 100644 index 000000000..38ce6f4d3 --- /dev/null +++ b/.github/workflows/increment-guard.yml @@ -0,0 +1,29 @@ +# Ensures that the current lib version is not yet published but executing the Gradle +# `checkVersionIncrement` task. + +name: Version Guard + +on: + push: + branches: + - '**' + +jobs: + check: + name: Check version increment + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + submodules: 'true' + + - uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: zulu + cache: gradle + + - name: Check version is not yet published + shell: bash + run: ./gradlew checkVersionIncrement --stacktrace diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..f7218c618 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,65 @@ +name: Publish + +on: + push: + branches: [master] + +jobs: + publish: + name: Publish to Maven repositories + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + submodules: 'true' + + - uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: zulu + cache: gradle + + - name: Decrypt CloudRepo credentials + run: ./config/scripts/decrypt.sh "$CLOUDREPO_CREDENTIALS_KEY" ./.github/keys/cloudrepo.properties.gpg ./cloudrepo.properties + env: + CLOUDREPO_CREDENTIALS_KEY: ${{ secrets.CLOUDREPO_CREDENTIALS_KEY }} + + - name: Decrypt Git SSH credentials + run: ./config/scripts/decrypt.sh "$GIT_CREDENTIALS_KEY" ./.github/keys/deploy_key_rsa.gpg ./deploy_key_rsa + env: + GIT_CREDENTIALS_KEY: ${{ secrets.GIT_CREDENTIALS_KEY }} + + # Make sure the SSH key is not "too visible". SSH agent will not accept it otherwise. + - name: Set file system permissions + run: chmod 400 ./deploy_key_rsa && chmod +x ./config/scripts/register-ssh-key.sh + + - name: Decrypt GCS credentials + run: ./config/scripts/decrypt.sh "$GCS_CREDENTIALS_KEY" ./.github/keys/gcs-auth-key.json.gpg ./gcs-auth-key.json + env: + GCS_CREDENTIALS_KEY: ${{ secrets.GCS_CREDENTIALS_KEY }} + + - name: Decrypt GCAR credentials + run: ./config/scripts/decrypt.sh "$MAVEN_PUBLISHER_KEY" ./.github/keys/maven-publisher.json.gpg ./maven-publisher.json + env: + MAVEN_PUBLISHER_KEY: ${{ secrets.MAVEN_PUBLISHER_KEY }} + + - name: Decrypt Git SSH credentials + run: ./config/scripts/decrypt.sh "$GRADLE_PORTAL_CREDENTIALS_KEY" ./.github/keys/gradle-plugin-portal.secret.properties.gpg ./gradle-plugin-portal.secret.properties + env: + GRADLE_PORTAL_CREDENTIALS_KEY: ${{ secrets.GRADLE_PORTAL_CREDENTIALS_KEY }} + + - name: Append Gradle properties + run: cat ./gradle-plugin-portal.secret.properties >> ./gradle.properties + + - name: Publish artifacts to Maven + # Since we're in the `master` branch already, this means that tests of a PR passed. + # So, no need to run the tests again when publishing. + run: ./gradlew publish -x test --stacktrace + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FORMAL_GIT_HUB_PAGES_AUTHOR: developers@spine.io + # https://docs.github.com/en/actions/reference/environment-variables + REPO_SLUG: ${{ github.repository }} # e.g. SpineEventEngine/core-jvm + GOOGLE_APPLICATION_CREDENTIALS: ./maven-publisher.json + NPM_TOKEN: ${{ secrets.NPM_SECRET }} diff --git a/.github/workflows/remove-obsolete-artifacts-from-packages.yaml b/.github/workflows/remove-obsolete-artifacts-from-packages.yaml new file mode 100644 index 000000000..f70617100 --- /dev/null +++ b/.github/workflows/remove-obsolete-artifacts-from-packages.yaml @@ -0,0 +1,73 @@ +# +# Periodically removes obsolete artifacts from GitHub Packages. +# +# Only non-release artifactsβ€”those containing "SNAPSHOT" in their version nameβ€”are eligible +# for removal. The latest non-release artifacts will be retained, with the exact number determined +# by the `VERSION_COUNT_TO_KEEP` environment variable. +# +# Please note the following details: +# +# 1. An artifact cannot be deleted if it is public and has been downloaded more than 5,000 times. +# In this scenario, contact GitHub support for further assistance. +# +# 2. This workflow only applies to artifacts published from this repository. +# +# 3. A maximum of 100 artifacts can be removed per run from each package; +# if there are more than 100 obsolete artifacts, either manually restart the workflow +# or wait for the next scheduled removal. +# +# 4. When artifacts with version `x.x.x-SNAPSHOT` are published, GitHub automatically appends +# the current timestamp, resulting in versions like `x.x.x-SNAPSHOT.20241024.173759`. +# All such artifacts are grouped into one package and treated as a single package +# in GitHub Packages with the version `x.x.x-SNAPSHOT`. Consequently, it is not possible +# to remove obsolete versions within a package; only the entire package can be deleted. +# + +name: Remove obsolete Maven artifacts from GitHub Packages + +on: + schedule: + - cron: '0 0 * * *' # Run every day at midnight. + +env: + VERSION_COUNT_TO_KEEP: 5 # Number of most recent SNAPSHOT versions to retain. + +jobs: + retrieve-package-names: + name: Retrieve package names + runs-on: ubuntu-latest + outputs: + package-names: ${{ steps.request-package-names.outputs.package-names }} + steps: + - uses: actions/checkout@v4 + with: + submodules: 'true' + + - name: Retrieve the names of packages + id: request-package-names + shell: bash + run: | + repoName=$(echo ${{ github.repository }} | cut -d '/' -f2) + chmod +x ./config/scripts/request-package-names.sh + ./config/scripts/request-package-names.sh ${{ github.token }} \ + $repoName ${{ github.repository_owner }} ./package-names.json + echo "package-names=$(<./package-names.json)" >> $GITHUB_OUTPUT + + delete-obsolete-artifacts: + name: Delete obsolete artifacts + needs: retrieve-package-names + runs-on: ubuntu-latest + strategy: + matrix: + package-name: ${{ fromJson(needs.retrieve-package-names.outputs.package-names) }} + steps: + - name: Remove obsolete artifacts from '${{ matrix.package-name }}' package + uses: actions/delete-package-versions@v5 + with: + owner: ${{ github.repository_owner }} + package-name: ${{ matrix.package-name }} + package-type: 'maven' + token: ${{ github.token }} + min-versions-to-keep: ${{ env.VERSION_COUNT_TO_KEEP }} + # Ignores artifacts that do not contain the word "SNAPSHOT". + ignore-versions: '^(?!.+SNAPSHOT).*$' diff --git a/.gitignore b/.gitignore index 57a41e77e..19c333c03 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,29 @@ +# +# Copyright 2025, TeamDev. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Redistribution and use in source and/or binary forms, with or without +# modification, must retain the above copyright notice and the following +# disclaimer. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# + # Hugo cache files /site/resources public @@ -14,10 +40,27 @@ node_modules # Needed for navigation help inside code editors jsconfig.json -# The temporary Lychee cache obtained from local check -.lycheecache +# +# This file is used for two purposes: +# 1. ignoring files in the `config` project. +# 2. ignoring files in the projects that import `config` as a sub-module. +# +# Therefore, instructions below are superset of instructions required for all the projects. + +# Temporary output of AI agents. +.output + +# `jenv` local configuration. +.java-version + +# Internal tool directories. +.fleet/ +.junie/memory/ -# IntelliJ IDEA modules and interim config files +# Kotlin temp directories. +**/.kotlin/ + +# IntelliJ IDEA modules and interim config files. *.iml .idea/*.xml .idea/.name @@ -26,53 +69,104 @@ jsconfig.json .idea/modules .idea/shelf +# Do not ignore the following IDEA settings !.idea/misc.xml +!.idea/codeStyleSettings.xml !.idea/codeStyles/ !.idea/copyright/ .DS_Store +# Ignore IDEA config files under `tests` +/tests/.idea/** + # Gradle interim configs -.gradle/ +**/.gradle/** + +# Temp directory for Gradle TestKit runners +**/.gradle-test-kit/** + +# Integration test log files +/tests/_out/** # Generated source code -generated/ +**/generated/** +**/*.pb.dart +**/*.pbenum.dart +**/*.pbserver.dart +**/*.pbjson.dart + +# Generated source code with custom path under `tests` +/tests/**/proto-gen/** # Gradle build files -build/ +**/build/** +!**/src/**/build/** + +# Build files produced by the IDE +**/out/** + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar -# IDEA build files -out/ +# Cache of project +.gradletasknamecache -# Spine temporary artifact storage -.spine/ +# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 +# gradle/wrapper/gradle-wrapper.properties -# Temporarily add the Spine Protoc plugin until this Base issue is fixed: -# https://github.com/SpineEventEngine/base/issues/552 -# Update the file when you bump Spine dependency in `samples` or `examples`. -# The `.spine` directory must have all the versions of the plugin until the issue is fixed. -!/.spine/spine-protoc-plugin-1.5.21.jar +# Spine internal directory for storing intermediate artifacts +**/.spine/** -# Credentials to Maven repositories and Google Cloud Storage used for Travis build reports +# Login details to Maven repository. +# Each workstation should have developer's login defined in this file. credentials.tar credentials.properties cloudrepo.properties deploy_key_rsa gcs-auth-key.json -# -# The gradle.properties file should contain settings specific to a developer's workstation. -# -# See sample file for a Mac OS X workstation below. -# ------- -# # Set Java home to point to JDK8. This is need to generate classes working with Java8 API. -# # Otherwise the following warning appears during the build: -# # warning: [options] bootstrap class path not set in conjunction with -source 1.8 -# # -# # suppress inspection "UnusedProperty" -# org.gradle.java.home=/Library/Java/JavaVirtualMachines/jdk1.8.0_51.jdk/Contents/Home/ -# ------- -gradle.properties +# Log files +*.log + +# Package Files # +*.war +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +.packages +pubspec.lock + +# Ignore the `tmp` directory used for building dependant repositories. +/tmp + +# Python cache +__pycache__/ +*.pyc + +# Claude working files +/.claude/worktrees/ + +# Auto-downloaded Lychee binary used by the `check-links` skill. +/.agents/skills/check-links/.cache/ /.sass-cache/ -/buildSrc/.kotlin/ + +# Lychee link-checker cache (created by the `check-links` skill and +# the `Check Links` workflow when run locally). +.lycheecache + +# Hugo docs preview site build artifacts (used by the `check-links` +# skill and the `Check Links` workflow in repos that contain a +# `docs/_preview` Hugo site). +docs/_preview/node_modules/ +docs/_preview/public/ +docs/_preview/resources/ diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 5a399bd1a..f60c2734a 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,5 +1,6 @@ + - + \ No newline at end of file diff --git a/.idea/copyright/TeamDev_Open_Source.xml b/.idea/copyright/TeamDev_Open_Source.xml index 14d7385ef..cea7fede3 100644 --- a/.idea/copyright/TeamDev_Open_Source.xml +++ b/.idea/copyright/TeamDev_Open_Source.xml @@ -1,6 +1,6 @@ - diff --git a/.idea/dictionaries/common.xml b/.idea/dictionaries/common.xml index c6d06b803..d1c3a7bfe 100644 --- a/.idea/dictionaries/common.xml +++ b/.idea/dictionaries/common.xml @@ -5,6 +5,7 @@ arraybuffer aspx bytebuffer + callees closeables cqrs dartdocs @@ -23,6 +24,8 @@ handshaker hohpe idempotency + jspecify + kotest lempira liskov melnik @@ -44,15 +47,18 @@ processmanager procman proto's + protodata protos sfixed stderr stringifier stringifiers + substituter switchman testutil threeten tuples + unicast unregister unregistering unregisters @@ -62,4 +68,4 @@ yevsyukov - + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 44917ef5e..7be402da6 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -27,7 +27,9 @@