diff --git a/.agents/_TOC.md b/.agents/_TOC.md new file mode 100644 index 000000000..4be0656bd --- /dev/null +++ b/.agents/_TOC.md @@ -0,0 +1,23 @@ +# Table of Contents + +1. [Quick Reference Card](quick-reference-card.md) +2. [JVM project requirements](jvm-project.md) β€” language, build, and review checklist shared by all JVM repos +3. [Coding guidelines](coding-guidelines.md) +4. [Documentation & comments](documentation-guidelines.md) +5. [Documentation tasks](documentation-tasks.md) +6. [Running builds](running-builds.md) +7. [Version policy](version-policy.md) +8. [Project structure expectations](project-structure-expectations.md) +9. [Testing](testing.md) +10. [Safety rules](safety-rules.md) +11. [Advanced safety rules](advanced-safety-rules.md) +12. [Refactoring guidelines](refactoring-guidelines.md) +13. [Common tasks](common-tasks.md) +14. [Team memory](memory/MEMORY.md) +15. [Task plans](tasks/README.md) +16. [Java to Kotlin conversion](skills/java-to-kotlin/SKILL.md) +17. [Dependency update](skills/dependency-update/SKILL.md) +18. [Documentation review](skills/review-docs/SKILL.md) +19. [Pre-PR checklist](skills/pre-pr/SKILL.md) +20. [Kotlin code review](skills/kotlin-review/SKILL.md) +21. [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/.gitkeep b/.agents/memory/feedback/.gitkeep new file mode 100644 index 000000000..e69de29bb 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/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/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-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..8cf191de1 --- /dev/null +++ b/.agents/project.md @@ -0,0 +1,96 @@ +# Project: spine.io + +## Overview + +This repository is the source of the public [spine.io](https://spine.io) website β€” +the umbrella surface for the Spine SDK organisation. It serves a dual role: +a **documentation portal** (pulling the canonical content from the +[`documentation`][documentation] repository as a Hugo Module) and a +**marketing site** with landing pages, examples, and a `getting-help` page that +sells products through a Paygate-backed checkout. The site is built with +[Hugo Extended][hugo] and hosted on GitHub Pages. + +## Architecture + +**Role in the org:** application (a static website). Not a library, tool, or +Gradle plugin. The Gradle build present in the repo is a **convenience +wrapper only** β€” no JVM compilation happens here. + +**Composition** + +- `site/` β€” the Hugo project (config, layouts, assets, content shell). +- `config/` β€” the only git submodule, pointing at + [`SpineEventEngine/config`][config] (shared Spine build conventions). +- `build.gradle.kts` β€” exposes shell-backed tasks: + `:runSite`, `:buildSite`, and `:checkLinks`. + +**External pieces (consumed, not vendored)** + +- [`documentation`][documentation] β€” pulled in as a Hugo Module; provides the + documentation content. Code snippets shown on the rendered site are + embedded *there* (via `embed-code`) before being pulled in as a module. +- [`site-commons`][site-commons] β€” Hugo theme module shared across Spine + public sites (anchor icons, snackbars, etc.). + +**CI / deployment** (`.github/workflows/`) + +- `gh-pages.yml` β€” builds the Hugo site and deploys to GitHub Pages on push + to the default branch. This is the deploy path; treat changes that affect + it with care. +- `check-links.yml` β€” validates rendered-site links (mirrors the local + `checkLinks` task and the `check-links` skill). +- `gradle-wrapper-validation.yml` β€” guards the Gradle wrapper checksum on + PRs that touch the wrapper. + +## Constraints & guardrails + +- **Hugo Extended version is pinned.** Use `v0.161.1` or higher of the + *Extended* build. A mismatched/non-extended Hugo will break the theme + pipeline and SCSS. +- **The `config/` submodule is pinned to a specific commit.** Do not bump + it outside a dedicated update task β€” drive-by pointer changes silently + alter shared Spine build conventions. +- **Documentation content lives in the [`documentation`][documentation] + repo, not here.** Edits to documentation pages must be made there (or in + the relevant doc module). This repo only owns the site shell, landing + pages, and Spine-SDK-wide marketing surfaces. + +## Local development + +Two equivalent ways to run the site locally: + +1. Via Gradle (preferred β€” handles working directory and Hugo flags): + ```shell + ./gradlew :runSite # build + serve on http://localhost:1313 + ./gradlew :buildSite # build only, output under site/public + ./gradlew :checkLinks # validate links against the built site + ``` +2. Directly with Hugo, from inside `site/`: + ```shell + hugo server + ``` + +If Hugo Module fetches misbehave (stale cache, theme not updating), clear +the module cache and retry: + +```shell +hugo mod clean --all +``` + +The `getting-help` page integrates with **Paygate**. In dev it points at the +staging Paygate server; test the checkout flow with +[LHV sandbox cards](https://merchant.lhv.ee/help/en/articles/12807566-test-cards). +Changes touching that page have a real third-party integration surface β€” +verify the flow end-to-end, not just the rendered markup. + +## Authoring + +- Content authoring conventions: [`AUTHORING.md`](../AUTHORING.md). +- Embedding code samples into pages happens in the + [`documentation`][documentation] repo, not here β€” see its + [`EMBEDDING.md`](https://github.com/SpineEventEngine/documentation/blob/master/EMBEDDING.md). + +[hugo]: https://gohugo.io/getting-started/quick-start/#step-1-install-hugo +[documentation]: https://github.com/SpineEventEngine/documentation +[site-commons]: https://github.com/SpineEventEngine/site-commons +[config]: https://github.com/SpineEventEngine/config 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/.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/.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..88de51c2a --- /dev/null +++ b/.agents/scripts/pre-pr-gate.sh @@ -0,0 +1,75 @@ +#!/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 + +command -v jq >/dev/null 2>&1 || exit 0 + +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 </dev/null 2>&1 || exit 0 + +input=$(cat) +file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty') +command=$(printf '%s' "$input" | jq -r '.tool_input.command // empty') + +touches_version_file() { + if [ "$file" = "version.gradle.kts" ] || [ "${file%/version.gradle.kts}" != "$file" ]; then + return 0 + fi + + printf '%s\n' "$command" \ + | grep -qE '^\*\*\* (Add|Update|Delete) File: (.+/)?version\.gradle\.kts$' +} + +if touches_version_file; then + cat >&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..996bd2565 --- /dev/null +++ b/.agents/scripts/publish-version-gate.sh @@ -0,0 +1,95 @@ +#!/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 + +command -v jq >/dev/null 2>&1 || exit 0 + +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 </dev/null 2>&1 || exit 0 + +input=$(cat) +file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty') +command=$(printf '%s' "$input" | jq -r '.tool_input.command // empty') + +sanitize_file() { + local path="$1" + + [ -z "$path" ] && return 0 + [ ! -f "$path" ] && return 0 + + case "$path" in + *.java|*.kt|*.kts) ;; + *) return 0 ;; + esac + + tmp=$(mktemp) + awk ' + { sub(/[ \t]+$/, "") } + /^$/ { blank++; if (blank > 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..a4c61a0c5 --- /dev/null +++ b/.agents/skills/check-links/SKILL.md @@ -0,0 +1,325 @@ +--- +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 and work directory + +Before any other step, determine `SITE_DIR` (the Hugo site root) and `WORK_DIR` +(the directory where `npm ci` / `hugo` commands run β€” mirrors `.github/workflows/check-links.yml`): + +```bash +SITE_DIR="" +for dir in docs site; do + for cfg in hugo.toml hugo.yaml \ + config/hugo.toml config/hugo.yaml \ + config/_default/hugo.toml config/_default/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 + +if [ -f "${SITE_DIR}/_preview/package-lock.json" ]; then + WORK_DIR="${SITE_DIR}/_preview" +elif [ -f "${SITE_DIR}/package-lock.json" ]; then + WORK_DIR="${SITE_DIR}" +else + echo "ERROR: No package-lock.json found under ${SITE_DIR}/_preview/ or ${SITE_DIR}/." >&2 + exit 1 +fi +``` + +Use `$SITE_DIR` for content paths and `$WORK_DIR` for build/serve operations 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 ${WORK_DIR} && 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. + +### 4. Build the site + +Run `( cd ${WORK_DIR} && hugo -e development )`. +This emits `${WORK_DIR}/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 ${WORK_DIR} && 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/ \ + "${WORK_DIR}/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: + +`${WORK_DIR}/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. + - `${WORK_DIR}/node_modules/` β€” installed by `npm ci` in step 3. + - `${WORK_DIR}/public/` β€” Hugo's rendered HTML (the corpus Lychee scans). + - `${WORK_DIR}/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..e656b0b45 --- /dev/null +++ b/.agents/skills/pre-pr/SKILL.md @@ -0,0 +1,183 @@ +--- +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 a scope-dependent + build/check command per `.agents/running-builds.md` (docs-only β†’ `dokka`; + code/deps β†’ `build`; proto β†’ `clean build`; no documented command β†’ skipped), + and invoke the relevant 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 000000000..284b02a47 Binary files /dev/null and b/.agents/widow-runt-orphan.jpg differ diff --git a/.claude/agents/dependency-audit.md b/.claude/agents/dependency-audit.md new file mode 100644 index 000000000..9db010fe5 --- /dev/null +++ b/.claude/agents/dependency-audit.md @@ -0,0 +1,19 @@ +--- +name: dependency-audit +description: Audits changes to dependency declarations under `buildSrc/src/main/kotlin/io/spine/dependency/` β€” catches accidental version downgrades, BOM mismatches, missing deprecation markers, copyright drift, and convention drift. Use proactively whenever a diff touches that directory, or when the user asks "audit this dependency bump". Read-only; does not run builds. +tools: Read, Grep, Glob, Bash +model: claude-haiku-4-5-20251001 +--- + +Follow the `dependency-audit` skill exactly: + +- Skill: `.agents/skills/dependency-audit/SKILL.md` +- The skill owns the per-area checks (version sanity, naming and structure, + deprecation discipline, convention drift, cross-cutting) and the output + format (Must fix / Should fix / Nits + one-line verdict). +- Read-only: use `Read`, `Grep`, `Glob`, and `Bash` solely for `git diff`, + `git grep`, and related read-only inspection. Do not run builds. +- **Be fast.** Fetch the full unified diff once, work from it, and `Read` + individual files only when the skill's step 2 budget allows. Issue + independent `Grep`/`Bash` calls in parallel within a single response; + do not halt at the first failure β€” collect all findings and report once. diff --git a/.claude/agents/kotlin-review.md b/.claude/agents/kotlin-review.md new file mode 100644 index 000000000..74583aa33 --- /dev/null +++ b/.claude/agents/kotlin-review.md @@ -0,0 +1,17 @@ +--- +name: kotlin-review +description: Reviews Kotlin (and Java) changes against Spine coding guidelines, safety rules, and testing policy. Use proactively after any non-trivial code edit, before opening a PR, or when the user asks for a code review. Read-only; does not run builds. +tools: Read, Grep, Glob, Bash +model: inherit +--- + +Follow the `kotlin-review` skill exactly: + +- Skill: `.agents/skills/kotlin-review/SKILL.md` +- The skill owns the procedure, the checks (Kotlin idioms, safety rules, + testing policy, version-gate applicability), and the output format + (Must fix / Should fix / Nits + one-line verdict). +- Stay in scope: code only. If a documentation issue surfaces, note it + briefly as a Nit pointing at the `review-docs` agent. +- Read-only: use `Read`, `Grep`, `Glob`, and `Bash` solely for `git diff` + and related read-only inspection. Do not run builds. diff --git a/.claude/agents/review-docs.md b/.claude/agents/review-docs.md new file mode 100644 index 000000000..0481b240b --- /dev/null +++ b/.claude/agents/review-docs.md @@ -0,0 +1,18 @@ +--- +name: review-docs +description: Reviews documentation changes β€” KDoc/Javadoc inside Kotlin/Java sources and Markdown docs (`README.md`, `docs/**`) β€” against Spine documentation conventions. Use proactively when a diff touches doc comments or Markdown, before opening a doc-affecting PR, or when the user asks for a documentation review. Read-only; does not run builds. +tools: Read, Grep, Glob, Bash +model: inherit +--- + +Follow the `review-docs` skill exactly: + +- Skill: `.agents/skills/review-docs/SKILL.md` +- The skill owns the review procedure, the per-area checks (KDoc/Javadoc, + Markdown, prose flow, terminology), and the output format + (Must fix / Should fix / Nits + one-line verdict). +- Scope yourself to documentation only. If you spot a code-quality issue, + surface it briefly as a Nit pointing at the `kotlin-review` agent β€” + do not expand the review. +- Read-only: use `Read`, `Grep`, `Glob`, and `Bash` solely for `git diff` + and related read-only inspection. Do not run builds. diff --git a/.claude/commands/bump-gradle.md b/.claude/commands/bump-gradle.md new file mode 100644 index 000000000..f9078802c --- /dev/null +++ b/.claude/commands/bump-gradle.md @@ -0,0 +1,13 @@ +--- +description: Upgrade the Gradle wrapper to the latest release. +argument-hint: "[gradle-version]" +allowed-tools: Read, Edit, Bash(./gradlew:*), Bash(git status:*), Bash(git diff:*), WebFetch +--- + +Follow the `bump-gradle` skill exactly: + +- Skill: `.agents/skills/bump-gradle/SKILL.md` +- Read the skill first. +- Use https://docs.gradle.org/current/release-notes.html as the source of truth for the latest version. Do NOT rely on remembered Gradle versions. +- If the user supplied a version: $ARGUMENTS, use it; otherwise read it from the release notes. +- Commit the wrapper change and dependency report change in separate commits per the skill. diff --git a/.claude/commands/bump-version.md b/.claude/commands/bump-version.md new file mode 100644 index 000000000..82e18b599 --- /dev/null +++ b/.claude/commands/bump-version.md @@ -0,0 +1,13 @@ +--- +description: Bump the project version in version.gradle.kts per Spine SDK versioning policy. +argument-hint: "[snapshot|minor|major]" +allowed-tools: Read, Edit, Bash(git status:*), Bash(git diff:*), Bash(git log:*), Bash(./gradlew:*) +--- + +Follow the `bump-version` skill exactly: + +- Skill: `.agents/skills/bump-version/SKILL.md` +- Read the skill first; it owns the policy (snapshot numbering, version conflicts, rebuilding reports). +- Increment requested by the user: $ARGUMENTS (treat as "snapshot" if empty). +- Inspect `git status --short` before editing; preserve unrelated user changes. +- Stop and ask the user before committing. diff --git a/.claude/commands/dependency-update.md b/.claude/commands/dependency-update.md new file mode 100644 index 000000000..9c54da149 --- /dev/null +++ b/.claude/commands/dependency-update.md @@ -0,0 +1,16 @@ +--- +description: Refresh external dependency versions in buildSrc to their latest non-snapshot release. +argument-hint: "[--dry-run] [paths...]" +allowed-tools: Read, Edit, Write, Grep, Glob, WebFetch, Bash(git status:*), Bash(git diff:*), Bash(./gradlew build:*), Bash(./gradlew clean build:*) +--- + +Follow the `dependency-update` skill exactly: + +- Skill: `.agents/skills/dependency-update/SKILL.md` +- Scope / flags: $ARGUMENTS +- Walk every dependency object under `buildSrc/src/main/kotlin/io/spine/dependency/`. +- Source of truth per artifact: the URL in the file's comment (line `// https://...` or KDoc `@see`). If no URL, fall back to Maven Central AND back-fill the URL comment. +- Filter out snapshots, RCs, alphas, betas, milestones, EAPs, and `-dev` builds. +- Apply the edit. Do NOT commit; emit the report described in the skill. +- Flag `local/` (Spine SDK) updates separately so the user can decide whether to bump in lockstep. +- After the run, suggest the user review the diff and run `./gradlew build` (or `./gradlew clean build` if proto files participate). For `local/` bumps, suggest `./gradlew buildDependants`. diff --git a/.claude/commands/java-to-kotlin.md b/.claude/commands/java-to-kotlin.md new file mode 100644 index 000000000..6f2c072f9 --- /dev/null +++ b/.claude/commands/java-to-kotlin.md @@ -0,0 +1,13 @@ +--- +description: Convert Java files to idiomatic Kotlin, including Javadoc -> 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/.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/.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/check-links.yml b/.github/workflows/check-links.yml new file mode 100644 index 000000000..a67795431 --- /dev/null +++ b/.github/workflows/check-links.yml @@ -0,0 +1,215 @@ +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. Hugo config may live directly in the site root or in a + # `config/` or `config/_default/` subdirectory (both layouts are valid). + # Outputs `present=true|false`, `site_dir=docs|site`, and `work_dir` + # (the directory where `npm ci` / `hugo` commands should run β€” either + # `$site_dir/_preview` for repos that use a separate preview sub-tree, + # or `$site_dir` for repos whose Node/Hugo setup lives at the root). + # 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 config/hugo.toml config/_default/hugo.toml; do + if [ -f "$dir/$cfg" ]; then + echo "site_dir=$dir" >> "$GITHUB_OUTPUT" + if [ -f "$dir/_preview/package-lock.json" ]; then + echo "work_dir=$dir/_preview" >> "$GITHUB_OUTPUT" + echo "present=true" >> "$GITHUB_OUTPUT" + echo "::notice::Hugo site found under $dir/ (work_dir: $dir/_preview)" + elif [ -f "$dir/package-lock.json" ]; then + echo "work_dir=$dir" >> "$GITHUB_OUTPUT" + echo "present=true" >> "$GITHUB_OUTPUT" + echo "::notice::Hugo site found under $dir/ (work_dir: $dir)" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::notice::Hugo config found in $dir/ but no package-lock.json found β€” 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.work_dir }}/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.work_dir }} + run: npm ci + + - name: Build docs preview site + if: steps.docs.outputs.present == 'true' + working-directory: ${{ steps.docs.outputs.work_dir }} + 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.work_dir }} + 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.work_dir }}/public/**/*.html' diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 919a83bff..469ca37f9 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -30,7 +30,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v3 with: - node-version: '20' + node-version: '26' - name: Cache dependencies uses: actions/cache@v4 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/proof-links.yml b/.github/workflows/proof-links.yml deleted file mode 100644 index 9b93fd397..000000000 --- a/.github/workflows/proof-links.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: Proof Links -on: [pull_request, workflow_dispatch] - -env: - HUGO_VERSION: 0.161.1 - LYCHEE_RELEASE: "lychee-v0.15.1-x86_64-unknown-linux-gnu.tar.gz" - LYCHEE_VERSION_TAG: "v0.15.1" - -jobs: - proof-links: - runs-on: ubuntu-latest - concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Hugo - uses: peaceiris/actions-hugo@v3 - with: - hugo-version: ${{ env.HUGO_VERSION }} - extended: true - - - name: Setup Node - uses: actions/setup-node@v3 - with: - node-version: '20' - - - name: Cache dependencies - uses: actions/cache@v4 - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- - - - name: Cache Hugo Modules - uses: actions/cache@v4 - with: - path: /tmp/hugo_cache - key: ${{ runner.os }}-hugomod-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-hugomod- - - - name: Install Dependencies - working-directory: site - run: npm ci - - - name: Build Spine - working-directory: site - run: hugo -e development - - # Cache Lychee results to avoid hitting rate limits. - - name: Cache Lychee results - uses: actions/cache@v4 - with: - path: .lycheecache - key: cache-lychee-results - - - name: Cache Lychee executable - id: cache-lychee - uses: actions/cache@v4 - with: - path: lychee - key: ${{ runner.os }}-${{ env.LYCHEE_RELEASE }} - - # We use Lychee directly instead of a GitHub Action because it - # must have access the local Hugo server, which is not visible - # from the Docker-based action. - - name: Download Lychee executable - uses: robinraju/release-downloader@v1.7 - if: steps.cache-lychee.outputs.cache-hit != 'true' - with: - repository: "lycheeverse/lychee" - tag: ${{ env.LYCHEE_VERSION_TAG }} - fileName: ${{ env.LYCHEE_RELEASE }} - - - name: Extract Lychee executable - if: steps.cache-lychee.outputs.cache-hit != 'true' - run: | - mkdir -p lychee && - tar -xf ${{ env.LYCHEE_RELEASE }} -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 makes sure the server is up before the next step. - - name: Start Hugo server - working-directory: site - run: | - nohup hugo server \ - --environment development \ - > nohup.out 2> nohup.err < /dev/null & - sleep 5 - - - name: Check if the cache file exists - run: | - if [ -f ".lycheecache" ]; then - echo "Lychee cache exists." - else - echo "No lychee cache file." - fi - - - name: Check links - run: | - ./lychee/lychee --config lychee.toml --timeout 60 \ - --base http://localhost:1313/ \ - 'site/public/**/*.html' 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/.gitmodules b/.gitmodules index 81c04cfe6..94e8664d6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,15 +1,3 @@ -[submodule "_code/examples/hello"] - path = _code/examples/hello - url = https://github.com/spine-examples/hello -[submodule "_code/examples/airport"] - path = _code/examples/airport - url = https://github.com/spine-examples/airport -[submodule "_code/examples/blog"] - path = _code/examples/blog - url = https://github.com/spine-examples/blog -[submodule "_code/examples/kanban"] - path = _code/examples/kanban - url = https://github.com/spine-examples/kanban -[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/.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 @@