diff --git a/.agents/quick-reference-card.md b/.agents/quick-reference-card.md index e2be69cb..27105d2d 100644 --- a/.agents/quick-reference-card.md +++ b/.agents/quick-reference-card.md @@ -7,3 +7,9 @@ - Always include tests with code changes - Version bump required for all PRs ``` + +🚫 **Do not commit, push, or tag** 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 08e9b33d..e7fece3c 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/update-copyright.sh b/.agents/scripts/update-copyright.sh new file mode 100755 index 00000000..80885cb7 --- /dev/null +++ b/.agents/scripts/update-copyright.sh @@ -0,0 +1,44 @@ +#!/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 -eu + +input=$(cat) +file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty') +command=$(printf '%s' "$input" | jq -r '.tool_input.command // empty') + +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/bump-gradle/SKILL.md b/.agents/skills/bump-gradle/SKILL.md index 1fa6fd50..22f29578 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. diff --git a/.agents/skills/bump-version/SKILL.md b/.agents/skills/bump-version/SKILL.md index 7143c3e9..3e1d3d65 100644 --- a/.agents/skills/bump-version/SKILL.md +++ b/.agents/skills/bump-version/SKILL.md @@ -16,6 +16,23 @@ 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. +- 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. diff --git a/.agents/skills/dependency-audit/SKILL.md b/.agents/skills/dependency-audit/SKILL.md index 06a81248..010c16bc 100644 --- a/.agents/skills/dependency-audit/SKILL.md +++ b/.agents/skills/dependency-audit/SKILL.md @@ -39,12 +39,13 @@ Each file declares a Kotlin `object` extending `Dependency` or `DependencyWithBo ## How to run an audit -1. **Fetch the full diff once.** Run - `git diff ...HEAD -- 'buildSrc/src/main/kotlin/io/spine/dependency/**'` - (or `--staged` if the user is mid-commit). 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. +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 @@ -66,8 +67,17 @@ Each file declares a Kotlin `object` extending `Dependency` or `DependencyWithBo removed `const val`. - `rg -L 'Copyright \(c\) 2026' ` to flag every stale header in one call. - - `rg -n ':' --type kt --type gradle` once per - library to check for hardcoded pins. + - `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 @@ -77,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`). @@ -96,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/version-bumped/scripts/version-bumped.sh b/.agents/skills/version-bumped/scripts/version-bumped.sh index 20ad7227..f050a5b7 100755 --- a/.agents/skills/version-bumped/scripts/version-bumped.sh +++ b/.agents/skills/version-bumped/scripts/version-bumped.sh @@ -18,9 +18,29 @@ # 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 @@ -87,44 +107,132 @@ if [ -z "$committed" ] && [ -z "$worktree" ] && [ -z "$untracked" ]; then exit 0 fi -# --- Parse versionToPublish from a Gradle file content ------------------- -# Handles two shapes (per .agents/skills/bump-version/SKILL.md step 2): -# 1. val versionToPublish: String by extra("X") -# 2. val sourceVar: String by extra("X") -# val versionToPublish by extra(sourceVar) +# --- 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" - local v + local content="$1" name="$2" + local v varName + # Shape 1: literal. v=$(printf '%s' "$content" \ - | grep -E 'val[[:space:]]+versionToPublish[^=]*by[[:space:]]+extra\("' \ + | 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 - local varName + # Shapes 2 & 3: extract the alias source identifier. varName=$(printf '%s' "$content" \ - | grep -E 'val[[:space:]]+versionToPublish[^=]*by[[:space:]]+extra\(' \ + | 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}[^=]*by[[:space:]]+extra\(\"" \ + | 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" || true) +head_version=$(parse_version "$head_content" "$key" || true) if [ -z "$head_version" ]; then - echo "version-bumped: cannot parse versionToPublish from working-tree $version_file" >&2 + echo "version-bumped: cannot parse '$key' from working-tree $version_file" >&2 exit 2 fi @@ -135,9 +243,9 @@ if [ -z "$base_content" ]; then exit 0 fi -base_version=$(parse_version "$base_content" || true) +base_version=$(parse_version "$base_content" "$key" || true) if [ -z "$base_version" ]; then - echo "version-bumped: cannot parse versionToPublish from $base:$version_file" >&2 + echo "version-bumped: cannot parse '$key' from $base:$version_file" >&2 exit 2 fi @@ -151,13 +259,13 @@ else fi if [ "$cmp" = "greater" ]; then - [ "${VERSION_BUMPED_QUIET:-0}" = "1" ] || echo "version-bumped: OK ($base_version -> $head_version)" + [ "${VERSION_BUMPED_QUIET:-0}" = "1" ] || echo "version-bumped: OK ($key: $base_version -> $head_version)" exit 0 fi cat >&2 < `` ``, 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/.claude/settings.json b/.claude/settings.json index 0053d24c..22537b6a 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -84,6 +84,10 @@ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.agents/scripts/sanitize-source-code.sh" + }, + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.agents/scripts/update-copyright.sh" } ] } diff --git a/.codex/hooks.json b/.codex/hooks.json index d7337d0e..5f3ef200 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -31,6 +31,10 @@ { "type": "command", "command": "\"$(git rev-parse --show-toplevel)/.agents/scripts/sanitize-source-code.sh\"" + }, + { + "type": "command", + "command": "\"$(git rev-parse --show-toplevel)/.agents/scripts/update-copyright.sh\"" } ] } diff --git a/CLAUDE.md b/CLAUDE.md index 6d374c67..57963ba1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,10 @@ - Write the plan to `.agents/tasks/.md` before coding. See `.agents/tasks/README.md` for format and lifecycle. - If something goes wrong — STOP and re-plan immediately. - One focused task per subagent. +- **Never `git commit`, `git push`, `git tag`, or otherwise rewrite git history** unless + the active skill's `SKILL.md` has a `## Commit authorization` section, or the *current* user + prompt explicitly tells you to. Authorization does not carry over between turns. + See `.agents/safety-rules.md` → *Commits and history-writing*. ## Memory