diff --git a/.agents/_TOC.md b/.agents/_TOC.md index 520d29057c..4be0656bdf 100644 --- a/.agents/_TOC.md +++ b/.agents/_TOC.md @@ -1,7 +1,7 @@ # Table of Contents 1. [Quick Reference Card](quick-reference-card.md) -2. [Project overview](project-overview.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) @@ -13,9 +13,11 @@ 11. [Advanced safety rules](advanced-safety-rules.md) 12. [Refactoring guidelines](refactoring-guidelines.md) 13. [Common tasks](common-tasks.md) -14. [Java to Kotlin conversion](skills/java-to-kotlin/SKILL.md) -15. [Dependency update](skills/dependency-update/SKILL.md) -16. [Documentation review](skills/review-docs/SKILL.md) -17. [Pre-PR checklist](skills/pre-pr/SKILL.md) -18. [Kotlin code review](skills/kotlin-review/SKILL.md) -19. [Dependency audit](skills/dependency-audit/SKILL.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/coding-guidelines.md b/.agents/coding-guidelines.md index 12ede97cdc..8c0a60f34a 100644 --- a/.agents/coding-guidelines.md +++ b/.agents/coding-guidelines.md @@ -21,10 +21,12 @@ - **Generic parameters** over explicit variable types (`val list = mutableList()`) - **Java interop annotations** only when needed (`@file:JvmName`, `@JvmStatic`) - **Kotlin DSL** for Gradle files +- **Kotlin Protobuf DSL** (`myMessage { field = value }`) over Java builder chains ### ❌ Avoid - Mutable data structures - Java-style verbosity (builders with setters) +- Java Protobuf builders in Kotlin code (`newBuilder()`, `toBuilder()`) unless interop requires them - Redundant null checks (`?.let` misuse) - Using `!!` unless clearly justified - Type names in variable names (`userObject`, `itemList`) diff --git a/.agents/documentation-guidelines.md b/.agents/documentation-guidelines.md index 6c9c1bae76..58a64a396d 100644 --- a/.agents/documentation-guidelines.md +++ b/.agents/documentation-guidelines.md @@ -6,6 +6,11 @@ - When using TODO comments, follow the format on the [dedicated page][todo-comments]. - File and directory names should be formatted as code. +## Protobuf file headers +- In `.proto` files, a multi-paragraph documentation header must end with a + trailing empty comment line (`//`). +- Single-paragraph headers do not require the trailing empty comment line. + ## Avoid widows, runts, orphans, or rivers Agents should **AVOID** text flow patters illustrated diff --git a/.agents/jvm-project.md b/.agents/jvm-project.md new file mode 100644 index 0000000000..e3c5d650d1 --- /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 0000000000..2c8045c6ed --- /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 0000000000..899d9e5585 --- /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 0000000000..e69de29bb2 diff --git a/.agents/memory/feedback/copilot-review-request.md b/.agents/memory/feedback/copilot-review-request.md new file mode 100644 index 0000000000..f5dde9b460 --- /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 0000000000..e69de29bb2 diff --git a/.agents/memory/reference/.gitkeep b/.agents/memory/reference/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.agents/memory/reference/anthropic-api-caching.md b/.agents/memory/reference/anthropic-api-caching.md new file mode 100644 index 0000000000..bcb1be4ccd --- /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 0000000000..796dd4d303 --- /dev/null +++ b/.agents/memory/reference/cache-warm-window.md @@ -0,0 +1,33 @@ +--- +name: cache-warm-window +description: How prompt cache entries are shared between sibling-repo sessions and how to maximise overlap. +metadata: + type: reference + since: 2026-05-24 +--- + +Claude Code sessions share a prompt cache entry when they send byte-identical +content within the cache TTL window. Because `migrate` copies `CLAUDE.md` and +`.agents/` verbatim, any two sessions on the same config version share the +same cache slot — provided they fall within the TTL. + +**TTL in effect for Console OAuth users:** +- Default: **5 minutes** (applies to all non-subscription auth) +- With `ENABLE_PROMPT_CACHING_1H=1` in `~/.claude/settings.json`: **1 hour** + +Developers must have `ENABLE_PROMPT_CACHING_1H=1` set, otherwise the +window is too short for cross-session hits to occur reliably. +This setting will work ONLY for Claude Code which runs the CLI binary. +It will not work for JetBrains Air or any other IDE plugin which does not +run the Claude Code CLI binary. + +**Cache is per Anthropic workspace.** All developers authenticated via the +same Anthropic organisation Console org share the same cache pool. Do not +create separate Console workspaces per developer — that would isolate their +cache entries. + +**Practical impact:** Realistic concurrency is 1–2 sessions at a time. The +first session after a config change pays the cache-write cost; any session +starting within the next hour (with 1H TTL) reads from cache at 0.1× cost. + +Related: [[anthropic-api-caching]] diff --git a/.agents/project-overview.md b/.agents/project-overview.md deleted file mode 100644 index dfac73f03f..0000000000 --- a/.agents/project-overview.md +++ /dev/null @@ -1,7 +0,0 @@ -# 🛠️ Project overview - -- **Languages**: Kotlin (primary), Java (secondary). -- **Build tool**: Gradle with Kotlin DSL. -- **Static analysis**: detekt, ErrorProne, Checkstyle, PMD. -- **Testing**: JUnit 5, Kotest Assertions, Codecov. -- **Tools used**: Gradle plugins, IntelliJ IDEA Platform, KSP, KotlinPoet, Dokka. diff --git a/.agents/project.md b/.agents/project.md new file mode 100644 index 0000000000..4ab3fa90da --- /dev/null +++ b/.agents/project.md @@ -0,0 +1,33 @@ +# Project: Spine Compiler + +## Overview + +Spine Compiler is a collection of tools for generating quality domain models +from Protobuf definitions. It is part of the Spine SDK organisation and powers +code generation across Spine-based projects, turning `.proto` files into rich +domain types with validation, factories, and other model conveniences. + +## Architecture + +**Role**: Library + Gradle plugin + Protobuf compiler plugin. + +The repo is a multi-module Gradle build (`rootProject.name = "spine-compiler"`) +with these modules: + +- `api`, `api-tests` — public compiler API and its tests. +- `backend` — core code-generation engine. +- `params` — parameter/configuration model passed to the compiler. +- `cli` — command-line entry point. +- `protoc-plugin` — `protoc` plugin that invokes the compiler. +- `jvm` — JVM-specific code-generation support. +- `gradle-api`, `gradle-plugin` — Gradle integration. `gradle-plugin` is + published separately from the rest of the modules. +- `test-env`, `testlib` — shared test fixtures and utilities. + +Module artifacts are published under `io.spine.tools` (see +[`dependencies.md`](../dependencies.md) for the published coordinates). Public +API boundaries live in `api` and `gradle-api`; downstream Spine repos depend +on these. + +Read [`.agents/jvm-project.md`](jvm-project.md) for build stack, coding style, +tests, and versioning. diff --git a/.agents/project.template.md b/.agents/project.template.md new file mode 100644 index 0000000000..b6882e03af --- /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 index e2be69cb81..2e890e4289 100644 --- a/.agents/quick-reference-card.md +++ b/.agents/quick-reference-card.md @@ -1,9 +1,7 @@ # 📝 Quick Reference Card -``` -🔑 Key Information: -- Kotlin/Java project with CQRS architecture -- Follow coding guidelines in Spine Event Engine docs -- Always include tests with code changes -- Version bump required for all PRs -``` +🚫 **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/safety-rules.md b/.agents/safety-rules.md index 08e9b33d1f..e7fece3ccb 100644 --- a/.agents/safety-rules.md +++ b/.agents/safety-rules.md @@ -5,3 +5,45 @@ - ❌ 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 0000000000..c824ff1d5b --- /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 0000000000..de4c631a11 --- /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 0000000000..30f6049202 --- /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 0000000000..2b69d36bac --- /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 0000000000..a8456680a7 --- /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 0000000000..fa3e9c833e --- /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 0000000000..e145aaee6a --- /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/.claude/scripts/pre-pr-gate.sh b/.agents/scripts/pre-pr-gate.sh similarity index 91% rename from .claude/scripts/pre-pr-gate.sh rename to .agents/scripts/pre-pr-gate.sh index cb80b31251..3ba36c0e59 100755 --- a/.claude/scripts/pre-pr-gate.sh +++ b/.agents/scripts/pre-pr-gate.sh @@ -9,6 +9,15 @@ # set -eu +if ! command -v jq >/dev/null 2>&1; then + cat >&2 </dev/null 2>&1; then + cat >&2 <<'EOF' +This hook requires `jq` to validate edits to version.gradle.kts and cannot run safely without it. + +Install `jq` and retry. This hook fails closed to avoid silently allowing prohibited edits. +EOF + exit 2 +fi + 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$' +} -case "$file" in - */version.gradle.kts|version.gradle.kts) +if touches_version_file; then cat >&2 <<'EOF' Direct edits to version.gradle.kts are blocked by a project hook. @@ -31,7 +50,6 @@ See: - .agents/skills/bump-version/SKILL.md EOF exit 2 - ;; -esac +fi exit 0 diff --git a/.agents/scripts/publish-version-gate.sh b/.agents/scripts/publish-version-gate.sh new file mode 100755 index 0000000000..996bd25656 --- /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 0000000000..b25282fda6 --- /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 0000000000..b1622ffd10 --- /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 index e5d09269fd..22f295786d 100644 --- a/.agents/skills/bump-gradle/SKILL.md +++ b/.agents/skills/bump-gradle/SKILL.md @@ -16,6 +16,29 @@ 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. @@ -115,3 +138,11 @@ Always check that page at task time. Do not rely on remembered Gradle versions. 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-version/SKILL.md b/.agents/skills/bump-version/SKILL.md index 7143c3e9fa..8a882be885 100644 --- a/.agents/skills/bump-version/SKILL.md +++ b/.agents/skills/bump-version/SKILL.md @@ -16,6 +16,25 @@ skill's target repository, CI runs the `Version Guard` workflow, which invokes 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. Keep the + backticks around the version literal (for example, ``... -> `2.0.0``` ) and + do not escape them as ``\````. +- 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. @@ -64,6 +83,12 @@ branches or inspect commit subjects; the checks below are agent-side guardrails. Bump version -> `2.0.0-SNAPSHOT.183` ``` + Shell-safe example (no escaped backticks in the commit subject): + + ```bash + git commit -m 'Bump version -> `2.0.0-SNAPSHOT.183`' -- version.gradle.kts + ``` + Use the actual new version in the subject. Do not include unrelated files in this commit. diff --git a/.agents/skills/check-links/SKILL.md b/.agents/skills/check-links/SKILL.md new file mode 100644 index 0000000000..a4c61a0c5b --- /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 index dc52b5246d..010c16bced 100644 --- a/.agents/skills/dependency-audit/SKILL.md +++ b/.agents/skills/dependency-audit/SKILL.md @@ -39,17 +39,45 @@ Each file declares a Kotlin `object` extending `Dependency` or `DependencyWithBo ## How to run an audit -1. **Scope the diff.** - - Run `git diff --stat ...HEAD -- 'buildSrc/src/main/kotlin/io/spine/dependency/**'` - (or `--staged` if the user is mid-commit) and read the file list. - - If the diff is empty, ask the user which files to audit. - -2. **Read each changed file fully.** Don't trust the hunk in isolation — - `version` constants are often referenced elsewhere in the same file (e.g. - `runtimeVersion` reused as `embeddedVersion`). - -3. **Run the checks below in order. Stop the audit and surface a finding the - moment any check fails.** +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 @@ -59,7 +87,9 @@ Each file declares a Kotlin `object` extending `Dependency` or `DependencyWithBo `.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 via `grep -r ':'`. + 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`). @@ -78,9 +108,10 @@ 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 a `const val` outright, grep the repo with - `git grep ''` to confirm no caller is left behind. If callers exist, - this is a Must-fix. +- 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 diff --git a/.agents/skills/dependency-update/SKILL.md b/.agents/skills/dependency-update/SKILL.md index b863a41f09..7e70bc126c 100644 --- a/.agents/skills/dependency-update/SKILL.md +++ b/.agents/skills/dependency-update/SKILL.md @@ -242,13 +242,18 @@ When the run completes, emit a Markdown report with these sections: End with the suggested next steps: 1. Review the diff (`git diff buildSrc/src/main/kotlin/io/spine/dependency/`). -2. Run `./gradlew build` (or `./gradlew clean build` if `.proto` files +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). -3. If any `local/` artifacts moved, run `./gradlew buildDependants` (the - `ConfigTester` task) to confirm downstream repos still build. -4. Commit. The conventional message is - `chore(deps): refresh external versions` (or a more specific subject if - the diff is small). +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 diff --git a/.agents/skills/java-to-kotlin/SKILL.md b/.agents/skills/java-to-kotlin/SKILL.md index d3abdc2f7b..b9835f8f7a 100644 --- a/.agents/skills/java-to-kotlin/SKILL.md +++ b/.agents/skills/java-to-kotlin/SKILL.md @@ -49,3 +49,11 @@ 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/kotlin-review/SKILL.md b/.agents/skills/kotlin-review/SKILL.md index c55c8c49c9..6cbca1afe8 100644 --- a/.agents/skills/kotlin-review/SKILL.md +++ b/.agents/skills/kotlin-review/SKILL.md @@ -25,10 +25,14 @@ live in `.agents/`: 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. + Filter out config-distributed files (see `AGENTS.md § Code review` for the + exact list) before proceeding. If nothing remains after filtering, return + `APPROVE — all changes are config-distributed files.` and stop. 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). + - Kotlin Protobuf DSL (`message { ... }`) preferred over Java builders (`newBuilder()`, `toBuilder()`) in Kotlin. - Immutability by default. - No `!!` without justification. - No type names in variable names. diff --git a/.agents/skills/move-files/SKILL.md b/.agents/skills/move-files/SKILL.md index 2885f4828f..b92b05d3f5 100644 --- a/.agents/skills/move-files/SKILL.md +++ b/.agents/skills/move-files/SKILL.md @@ -25,7 +25,8 @@ description: > changes. 3. Move safely. - - Prefer `git mv` for tracked files in the repo. + - 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. @@ -40,6 +41,13 @@ description: > - 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. diff --git a/.agents/skills/pre-pr/SKILL.md b/.agents/skills/pre-pr/SKILL.md index ca09c41ffe..591b26b308 100644 --- a/.agents/skills/pre-pr/SKILL.md +++ b/.agents/skills/pre-pr/SKILL.md @@ -2,10 +2,12 @@ name: pre-pr description: > Run the pre-PR checklist for this repo: apply the version gate only when - the repository has a root `version.gradle.kts`, run the configured - build/check command per `.agents/running-builds.md`, and invoke the - configured reviewers (`kotlin-review`, `review-docs`, `dependency-audit`) - against the branch diff. On success, write a sentinel file at + 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. @@ -18,62 +20,60 @@ 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` (for example, shared -configuration repositories). 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**. +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 based on what - changed. It may be Gradle or another repository-specific command. +- `.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. -- The reviewer skills/agents themselves: `kotlin-review` (Claude agent), - `review-docs` (skill + Claude agent), `dependency-audit` (Claude agent). ## Procedure -Execute the steps in order. If a step fails, stop, write a `FAIL` sentinel -(see step 6), and report the failure — do not run the remaining steps. +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. -- Diff command: `git diff ...HEAD --name-only` for the file list, - `git diff ...HEAD --stat` for the summary. -- Repository root: `git rev-parse --show-toplevel`. -- Version gate: - - Check only the repository-root `version.gradle.kts`. - - If `version.gradle.kts` is absent at both `` and `HEAD`, record the - version check as `N/A` and continue. Do not ask the user to run - `/bump-version`. - - If `version.gradle.kts` exists at `HEAD`, enforce the version check in - step 2. - - If `version.gradle.kts` exists at `` but is missing at `HEAD`, fail - unless the user explicitly asked to migrate the repository away from - Gradle Build Tools versioning. -- Classify the changes: - - **proto** — any `*.proto` file changed. - - **code** — any `*.kt`, `*.kts`, or `*.java` file changed. - - **docs** — any `*.md` file or doc-only edits inside sources changed. - - **deps** — any file under `buildSrc/src/main/kotlin/io/spine/dependency/` - changed. +- Changed files: `git diff ...HEAD --name-only` + Remove any path matching the config-distributed list in + `AGENTS.md § Code review`. A PR that contains *only* config-distributed + files needs no build, no reviewers, and should PASS immediately — skip + to step 6 with `build=skipped`, `build_status=skipped`, + `reviewers=none`, `version=not-applicable`. +- 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 -- If the version gate is `N/A`, skip this step with note: - "`version.gradle.kts` is absent; this repository is not a versioned Gradle - Build Tools project." -- Otherwise, read `version.gradle.kts` at `HEAD` and, when present, at - ``. -- Confirm the version string is strictly greater (semver + Spine snapshot - rules — see `.agents/version-policy.md`) when both sides have the file. -- If the file is newly introduced at `HEAD`, report the introduced version and - continue. -- If unchanged or decreased, stop with a Must-fix: "Run `/bump-version`." +- 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 @@ -81,93 +81,108 @@ Pick the target per `.agents/running-builds.md`: - **proto** changed → `./gradlew clean build` - Else **code** changed → `./gradlew build` -- Else **docs**-only → `./gradlew dokka` (tests not required) +- Else **docs**-only → `./gradlew dokka` -If the repository does not have `./gradlew`, do not fail solely because Gradle -is unavailable. Read `.agents/running-builds.md` for the repository-specific -non-Gradle command that matches the change type, and run that instead. If no -build/check command is documented for the change type, record `build=skipped` -with the reason and continue. +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. Surface the first failing module/task/check. On -failure, stop and write a `FAIL` sentinel. +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 the relevant reviewers concurrently and collect their verdicts: - -- Always: `kotlin-review` (if **code** changed) and `review-docs` (if - **docs** or KDoc changed). -- If **deps** changed: `dependency-audit`. - -Pass each reviewer the base ref, changed-file list, build/check result, and -version-check result. When the version check is `N/A`, say explicitly: -"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 and emits a Must-fix / Should-fix / Nits -report plus a one-line verdict (`APPROVE`, `APPROVE WITH CHANGES`, or -`REQUEST CHANGES`). +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 -- Overall **PASS** when: - - Version check passed or was `N/A`, - - Build succeeded, - - Every dispatched reviewer returned `APPROVE` or `APPROVE WITH CHANGES` - *and* no Must-fix items remain unaddressed in this session. -- Otherwise **FAIL**. +- **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 (NOT under `.claude/` — the -sentinel must travel with the local clone, not be checked in). Format: +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= -reviewers= +build= +build_status=PASS|FAIL|skipped +reviewers= version=new, introduced:, or "not-applicable"> ``` -The `gh pr create` hook (`.claude/scripts/pre-pr-gate.sh`) checks this -file's `head=` and `status=` fields. Extra fields are allowed. The sentinel is -invalidated automatically when HEAD advances — the hook compares the recorded -`head=` against the current HEAD SHA. - ## Output format -Report in this shape: +**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 checklist ( vs ) - -| Check | Status | Notes | -|---------------|--------|----------------------------------------| -| Version check | … | , introduced, or N/A | -| Build/check | … | | -| kotlin-review | … | | -| review-docs | … | | -| dep audit | … | | - -**Overall: PASS|FAIL** -Sentinel: .git/pre-pr.ok (status=PASS|FAIL, head=) +Pre-PR: FAIL ( vs ) + +Must fix: +- [kotlin-review] +- [review-docs] + +Should fix: +- [dependency-audit] ``` -On `PASS`, end with: "You can now run `gh pr create`." -On `FAIL`, end with the specific blocker and the next action. +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. It only gates whether the - workspace is ready. -- This skill must NOT create `version.gradle.kts`. Repositories without a root - `version.gradle.kts` are valid; their version check is `N/A`. -- The sentinel lives under `.git/` (untracked by definition) so it is - per-clone and never committed. -- Each reviewer remains the source of truth for its own checks; this - skill does not duplicate their rules — it only orchestrates and - aggregates. +- 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 index d936fa28a2..d7cac33237 100644 --- a/.agents/skills/review-docs/SKILL.md +++ b/.agents/skills/review-docs/SKILL.md @@ -34,8 +34,12 @@ The authoritative standards live in `.agents/`: `git diff ...HEAD` depending on what the user describes. Restrict to files matching: - `**/*.kt`, `**/*.kts`, `**/*.java` (for KDoc/Javadoc inside sources) + - `**/*.proto` (for file-level documentation headers) - `**/*.md` (Markdown docs) Do **not** review the full repo — only what changed. + Filter out config-distributed files (see `AGENTS.md § Code review` for the + exact list) before proceeding. If nothing remains after filtering, return + `APPROVE — all changes are config-distributed files.` and stop. 2. **Read each affected file fully, not just the hunks.** Prose review requires surrounding context — judging widows/runts/orphans, link @@ -65,6 +69,9 @@ The authoritative standards live in `.agents/`: `// 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. +- **Multi-paragraph Protobuf headers end with an empty comment line.** In + `.proto` files, if the file-level documentation header has more than one + paragraph, it must end with a trailing empty comment line (`//`). ### B. Markdown docs diff --git a/.agents/skills/version-bumped/SKILL.md b/.agents/skills/version-bumped/SKILL.md new file mode 100644 index 0000000000..86ca53df04 --- /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 0000000000..f050a5b79d --- /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 <.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/archive/api-discovery.md b/.agents/tasks/archive/api-discovery.md new file mode 100644 index 0000000000..1671f5eac1 --- /dev/null +++ b/.agents/tasks/archive/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/archive/prohibit-automatic-commits.md b/.agents/tasks/archive/prohibit-automatic-commits.md new file mode 100644 index 0000000000..ff067c505a --- /dev/null +++ b/.agents/tasks/archive/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/archive/prompt-caching-org.md b/.agents/tasks/archive/prompt-caching-org.md new file mode 100644 index 0000000000..71f0c4fb9a --- /dev/null +++ b/.agents/tasks/archive/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/archive/setup-cross-tool-agent-instructions.md b/.agents/tasks/archive/setup-cross-tool-agent-instructions.md new file mode 100644 index 0000000000..02672e2c8f --- /dev/null +++ b/.agents/tasks/archive/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/.claude/agents/dependency-audit.md b/.claude/agents/dependency-audit.md index 109456b839..9db010fe55 100644 --- a/.claude/agents/dependency-audit.md +++ b/.claude/agents/dependency-audit.md @@ -2,7 +2,7 @@ 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: inherit +model: claude-haiku-4-5-20251001 --- Follow the `dependency-audit` skill exactly: @@ -13,3 +13,7 @@ Follow the `dependency-audit` skill exactly: 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/scripts/sanitize-source-code.kt b/.claude/scripts/sanitize-source-code.kt deleted file mode 100755 index 357d789c55..0000000000 --- a/.claude/scripts/sanitize-source-code.kt +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# -# PostToolUse hook: enforce the source-code formatting rules from -# .agents/coding-guidelines.md after Edit/Write/MultiEdit: -# - strip trailing whitespace -# - replace 2+ consecutive blank lines with a single blank line -# -# Input: hook JSON on stdin (Claude Code passes tool_input.file_path). -# Exit: 0 always (post-tool-use; never block). -# -set -eu - -input=$(cat) -file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty') - -[ -z "$file" ] && exit 0 -[ ! -f "$file" ] && exit 0 - -case "$file" in - *.java|*.kt|*.kts) ;; - *) exit 0 ;; -esac - -tmp=$(mktemp) -awk ' - { sub(/[ \t]+$/, "") } - /^$/ { blank++; if (blank > 1) next; print; next } - { blank = 0; print } -' "$file" > "$tmp" && mv "$tmp" "$file" diff --git a/.claude/settings.json b/.claude/settings.json index 21fd266d0f..f7bbfb98ff 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -15,6 +15,7 @@ "Bash(git restore:*)", "Bash(git stash:*)", "Bash(git fetch:*)", + "Bash(git push:*)", "Bash(git rev-parse:*)", "Bash(git ls-files:*)", "Bash(git mv:*)", @@ -34,7 +35,6 @@ "Bash(./config/migrate)" ], "deny": [ - "Bash(git push:*)", "Bash(git reset --hard:*)", "Bash(git clean -fdx:*)", "Bash(rm -rf /:*)", @@ -59,7 +59,7 @@ "hooks": [ { "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/protect-version-file.sh" + "command": "$CLAUDE_PROJECT_DIR/.agents/scripts/protect-version-file.sh" } ] }, @@ -68,7 +68,11 @@ "hooks": [ { "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/pre-pr-gate.sh" + "command": "$CLAUDE_PROJECT_DIR/.agents/scripts/pre-pr-gate.sh" + }, + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.agents/scripts/publish-version-gate.sh" } ] } @@ -79,7 +83,11 @@ "hooks": [ { "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/sanitize-source-code.kt" + "command": "$CLAUDE_PROJECT_DIR/.agents/scripts/sanitize-source-code.sh" + }, + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.agents/scripts/update-copyright.sh" } ] } diff --git a/.codecov.yml b/.codecov.yml index b5739f89bc..e05fc77b61 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -12,15 +12,15 @@ coverage: - "**/test/**/*" status: # https://docs.codecov.com/docs/github-checks#yaml-configuration-for-github-checks-and-codecov - patch: false + patch: off # https://docs.codecov.com/docs/commit-status project: default: target: auto threshold: 0.05% - base: auto paths: - - "src" + - "src/main/**/*" + - "**/src/main/**" if_ci_failed: error informational: false only_pulls: true diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..8a5ab934a9 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,45 @@ +# 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). + +## Do not review + +Never review `gradlew` or `gradlew.bat` in any repository, including `config`. +These files are provided by Gradle and are not edited manually. + +If the current repository is `config`, review its files normally unless noted +above: they are authoritative there. In other repositories, the following files are managed by +the `config` submodule and must be reviewed in the `config` repository, not +here. In those consumer repositories, skip them without comment: + +- `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md` +- `.agents/**` (except `.agents/project.md`) +- `.claude/**`, `.idea/**`, `.junie/**` +- `.github/copilot-instructions.md` +- `buildSrc/**` (except `buildSrc/src/main/kotlin/module.gradle.kts`) +- `gradle/`, `gradlew`, `gradlew.bat` +- `.codecov.yml`, `.gitignore`, `gradle.properties`, `lychee.toml` +- `.github/workflows/` — unless the workflow was introduced by this repo + +## Universal rules + +**Do not suggest:** +- Any git history operation — `git commit`, `git push`, `git tag`, + `git rebase`, `git merge`, `git cherry-pick`, `gh pr merge`, or any other + command that writes to history — leave these to the developer. +- Auto-updating dependency versions outside a dedicated update task. +- Feature flags, backwards-compatibility shims, or fallbacks for scenarios + that cannot occur in the current codebase. +- Analytics, telemetry, or tracking code. +- Reflection or unsafe code without explicit approval. diff --git a/.github/workflows/build-on-ubuntu.yml b/.github/workflows/build-on-ubuntu.yml index cd6b93714c..b86496363b 100644 --- a/.github/workflows/build-on-ubuntu.yml +++ b/.github/workflows/build-on-ubuntu.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/setup-java@v4 with: java-version: 17 - distribution: zulu + distribution: temurin cache: gradle - name: Build project and run tests diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml new file mode 100644 index 0000000000..3fa235be0b --- /dev/null +++ b/.github/workflows/check-links.yml @@ -0,0 +1,216 @@ +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` and `work_dir` (the directory where + # `npm ci` / `hugo` commands should run — either `$dir/_preview` for + # repos that use a separate preview sub-tree, or `$dir` for repos whose + # Node/Hugo setup lives at the site 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/hugo.yaml \ + config/_default/hugo.toml config/_default/hugo.yaml; do + if [ -f "$dir/$cfg" ]; then + 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/increment-guard.yml b/.github/workflows/increment-guard.yml index 38ce6f4d3e..5201b9ba20 100644 --- a/.github/workflows/increment-guard.yml +++ b/.github/workflows/increment-guard.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/setup-java@v4 with: java-version: 17 - distribution: zulu + distribution: temurin cache: gradle - name: Check version is not yet published diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f7218c618e..53792a4806 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/setup-java@v4 with: java-version: 17 - distribution: zulu + distribution: temurin cache: gradle - name: Decrypt CloudRepo credentials diff --git a/.gitignore b/.gitignore index c43c66220c..38e5ad4b6d 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,17 @@ __pycache__/ # Claude working files /.claude/worktrees/ + +# Auto-downloaded Lychee binary used by the `check-links` skill. +/.agents/skills/check-links/.cache/ + +# Lychee link-checker cache (created by the `check-links` skill and +# the `Check Links` workflow when run locally). +.lycheecache + +# Hugo docs preview site build artifacts (used by the `check-links` +# skill and the `Check Links` workflow in repos that contain a +# `docs/_preview` Hugo site). +docs/_preview/node_modules/ +docs/_preview/public/ +docs/_preview/resources/ diff --git a/.idea/live-templates/README.md b/.idea/live-templates/README.md index 66713b347f..950066731a 100644 --- a/.idea/live-templates/README.md +++ b/.idea/live-templates/README.md @@ -5,12 +5,12 @@ This directory contains two live template groups: 1. `Spine.xml`: shortcuts for the repeated patterns used in the framework. 2. `User.xml`: a single shortcut to generate TODO comments. -### Instlallation +### Installation Live templates are not picked up by IDEA automatically. They should be added manually. In order to add these templates, perform the following steps: -1. Copy `*.xml` files from this directory to `templates` directory in the IntelliJ IDEA +1. Copy `*.xml` files from this directory to `templates` directory in the IntelliJ IDEA [settings folder][settings_folder]. 2. Restart IntelliJ IDEA: `File -> Invalidate Caches -> Just restart`. 3. Go to `Preferences -> Editor -> Live Templates`. @@ -23,5 +23,5 @@ In order to add these templates, perform the following steps: 1. Open the corresponding template: `Preferences -> Editor -> Live Templates -> User.todo`. 2. Click on `Edit variables`. 3. Set `USER` variable to your domain email address without `@teamdev.com` ending. For example, - for `jack.sparrow@teamdev.com` use the follwoing expression `"jack.sparrow"`. + for `jack.sparrow@teamdev.com` use the following expression `"jack.sparrow"`. 4. Verify that the template generates expected comments: `// TODO:2022-11-03:jack.sparrow: <...>`. diff --git a/.idea/live-templates/User.xml b/.idea/live-templates/User.xml index cc156507ea..958e2ea9be 100644 --- a/.idea/live-templates/User.xml +++ b/.idea/live-templates/User.xml @@ -1,7 +1,7 @@