diff --git a/.gitignore b/.gitignore index ca1a0a1..c71108e 100644 --- a/.gitignore +++ b/.gitignore @@ -293,3 +293,8 @@ pyrightconfig.json # agents-init --agents AGENTS/ # ──────────────────────────────────────────────────────── + +# ──────────────── Added by agents-init ────────────────── +# agents-init --plugins +docs/devlogs +# ──────────────────────────────────────────────────────── diff --git a/docs/fish-config.index b/docs/fish-config.index index 32ee3fa..9660d6e 100644 --- a/docs/fish-config.index +++ b/docs/fish-config.index @@ -135,6 +135,14 @@ smart-exit=### smart_exit ai=## 5.12 AI and Developer Tools antigravity-ide=### antigravity-ide agy=### agy +agents-init=### agents-init +agents=### agents-init +agents-tools=### agents-init +version-bump=### agents-init +.version=### agents-init +structure-version=### agents-init +core.hooksPath=### agents-init +devlogs=### agents-init claude-cli=### claude claude-docs=### claude-docs claude-pr=### claude-pr diff --git a/docs/fish-config.md b/docs/fish-config.md index 630ba2d..232c735 100644 --- a/docs/fish-config.md +++ b/docs/fish-config.md @@ -1127,17 +1127,30 @@ Add -i (interactive confirmation) to destructive commands: ### agents-init Synopsis: agents-init [--agents | --plugins] - Scaffold an AGENTS/ sub-repository for tracking agent specs and plugin - directories. Creates AGENTS/ as a standalone git repo, initializes - standard plugin subdirectories (superpowers/, plans/, specs/), moves any - existing AGENTS.md and docs/plugin dirs into the sub-repo, and replaces - them with relative symlinks. Also adds CLAUDE.md -> AGENTS/AGENTS.md so - Claude Code picks up the shared agent instructions. Adds all managed - paths to .gitignore and auto-commits every change inside the AGENTS/ - sub-repo. Fully idempotent: a second run produces no output and no new - commits. Flags: --agents re-runs only the AGENTS.md / symlink step; - --plugins re-runs only the plugin-directory wiring step. Called - automatically by the claude and agy wrappers on every invocation. + Scaffold an AGENTS/ sub-repository for tracking agent specs, plans, specs, + and dev logs. Creates AGENTS/ as a standalone git repo, moves any existing + AGENTS.md into it, and replaces it with a relative symlink (plus + CLAUDE.md -> AGENTS/AGENTS.md so Claude Code picks up the shared agent + instructions). Consolidates plans/ and specs/ directly under AGENTS/ + (merging any legacy docs/plans, docs/superpowers/plans, or old + AGENTS/plugins/ locations into the canonical AGENTS/), creates + AGENTS/devlogs/, and wires docs/superpowers/{plans,specs} symlinks back to + them. Adds managed paths to .gitignore and auto-commits every change inside + the AGENTS/ sub-repo; pulls first when the sub-repo has an upstream. + Fully idempotent: a second run produces no output and no new commits. + Flags: --agents re-runs only the AGENTS.md / symlink step; --plugins + re-runs only the plans/specs/devlogs wiring step. Called automatically by + the claude and agy wrappers on every invocation. + + Structure versioning: each AGENTS/ repo carries a self-contained version + bumper. AGENTS/.version holds MAJOR.MINOR.PATCH (seeded 1.0.0). Committed + git hooks under AGENTS/.agents-tools/ (wired via core.hooksPath) bump it on + every commit: MINOR (resetting PATCH) when the tracked directory set + changes, PATCH otherwise; MAJOR is manual-only. A prepare-commit-msg hook + appends "(vX.Y.Z)" to the commit subject. Downstream tooling can read + AGENTS/.version - a changed MINOR field signals a structure change. The + script and hooks are shipped from scripts/agents-tools/ and refreshed when + their version marker is stale. agents-init agents-init --agents diff --git a/functions/_agents_init_install_tools.fish b/functions/_agents_init_install_tools.fish new file mode 100644 index 0000000..c9f7c08 --- /dev/null +++ b/functions/_agents_init_install_tools.fish @@ -0,0 +1,48 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# SYNOPSIS +# _agents_init_install_tools +# +# DESCRIPTION +# Copies the canonical version-bump script and git hook shims from +# fish-config's scripts/agents-tools/ into /.agents-tools/, +# refreshing them when the shipped `agents-tools-version:` marker is newer +# than the installed copy. Files are made executable. Idempotent: prints +# nothing when the installed tooling is already current, or a short summary +# line when it installed or updated the tooling. +# +# ARGUMENTS +# agents_dir Absolute path to the AGENTS/ sub-repo root +# +# RETURNS +# 0 Tooling is current or was installed/updated successfully +# 1 Canonical source missing or a copy failed +# +# EXAMPLE +# set -l msg (_agents_init_install_tools /path/to/AGENTS) +# test -n "$msg"; and echo $msg +function _agents_init_install_tools --argument-names agents_dir + test -n "$agents_dir"; or return 1 + set -l src (path resolve (status dirname)/../scripts/agents-tools) + test -f "$src/version-bump"; or return 1 + set -l dest "$agents_dir/.agents-tools" + + set -l want (command grep -m1 -oE 'agents-tools-version: *[0-9]+' "$src/version-bump" 2>/dev/null | command grep -oE '[0-9]+$') + set -l have "" + test -f "$dest/version-bump"; and set have (command grep -m1 -oE 'agents-tools-version: *[0-9]+' "$dest/version-bump" 2>/dev/null | command grep -oE '[0-9]+$') + + test "$want" = "$have"; and return 0 + + mkdir -p "$dest/hooks"; or return 1 + command cp "$src/version-bump" "$dest/version-bump"; or return 1 + command cp "$src/hooks/pre-commit" "$dest/hooks/pre-commit"; or return 1 + command cp "$src/hooks/prepare-commit-msg" "$dest/hooks/prepare-commit-msg"; or return 1 + chmod +x "$dest/version-bump" "$dest/hooks/pre-commit" "$dest/hooks/prepare-commit-msg"; or return 1 + + if test -z "$have" + echo "→ Installed AGENTS/.agents-tools/ (version-bump v$want)" + else + echo "→ Updated AGENTS/.agents-tools/ (v$have → v$want)" + end +end diff --git a/functions/agents-init.fish b/functions/agents-init.fish index a2ee4d5..bca1ba4 100644 --- a/functions/agents-init.fish +++ b/functions/agents-init.fish @@ -17,7 +17,27 @@ # or symlink → AGENTS.md (single-source case) # /AGENTS.md → AGENTS/AGENTS.md # /CLAUDE.md → AGENTS/CLAUDE.md -# docs/ → ../AGENTS/plugins/ +# AGENTS/plans superpowers plans (real dir, .gitkeep) +# AGENTS/specs superpowers specs (real dir, .gitkeep) +# AGENTS/devlogs agent development logs (real dir, .gitkeep) +# AGENTS/.version MAJOR.MINOR.PATCH structure version (seed 1.0.0) +# AGENTS/.agents-tools/ committed version-bump script + git hook shims +# docs/superpowers/plans → ../../AGENTS/plans (always) +# docs/superpowers/specs → ../../AGENTS/specs (always) +# docs/plans → ../AGENTS/plans (only if docs/plans existed) +# docs/specs → ../AGENTS/specs (only if docs/specs existed) +# docs/devlogs → ../AGENTS/devlogs (only if docs/devlogs existed) +# +# plans/ and specs/ are merged from every legacy location (docs/, +# docs/superpowers/, and the old AGENTS/plugins/ layout) into the +# canonical AGENTS/; the AGENTS/plugins/ layer is removed. +# +# Each AGENTS repo carries a self-contained version bumper wired via +# core.hooksPath: a pre-commit hook bumps AGENTS/.version on every commit +# (MINOR when the tracked directory set changes, PATCH otherwise; MAJOR is +# manual-only), and a prepare-commit-msg hook appends "(vX.Y.Z)" to the +# commit subject. The script/hooks are version-managed from +# scripts/agents-tools/ and refreshed when their marker is stale. # # With no flags, runs both --agents and --plugins setup. At the end of # every invocation, commits any uncommitted changes in the AGENTS/ sub-repo @@ -25,7 +45,7 @@ # # ARGUMENTS # -a, --agents Set up AGENTS/ repo + AGENTS.md / CLAUDE.md symlinks only -# -p, --plugins Set up AGENTS/ repo + plugins dirs + docs/ symlinks only +# -p, --plugins Set up AGENTS/ repo + plans/specs/devlogs dirs + docs/ symlinks only # -v, --verbose Print all per-step output (default) # -q, --quiet Print one summary line only if changes were made # -s, --silent Suppress all output; errors only (standard UNIX convention) @@ -61,7 +81,7 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi echo "$c_head""Options:$c_reset" echo " $c_flag-h$c_reset, $c_flag--help$c_reset Show this help message" echo " $c_flag-a$c_reset, $c_flag--agents$c_reset Set up AGENTS.md / CLAUDE.md symlinks only" - echo " $c_flag-p$c_reset, $c_flag--plugins$c_reset Set up plugins dirs and docs/ symlinks only" + echo " $c_flag-p$c_reset, $c_flag--plugins$c_reset Set up plans/specs/devlogs dirs and docs/ symlinks only" echo " $c_flag-v$c_reset, $c_flag--verbose$c_reset Print all per-step output (default)" echo " $c_flag-q$c_reset, $c_flag--quiet$c_reset Print one summary line only if changes were made" echo " $c_flag-s$c_reset, $c_flag--silent$c_reset Suppress all output; only errors are printed" @@ -123,6 +143,26 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi test $verbose -eq 1; and echo "$c_ok→ Initialized git repo in AGENTS/$c_reset" end + # ─────────────── Always: version tracking (.version + hooks) ─────────────── + if not test -f "$agents_dir/.version" + echo 1.0.0 >"$agents_dir/.version" + set changed 1 + test $verbose -eq 1; and echo "$c_ok→ Created AGENTS/.version (1.0.0)$c_reset" + end + + set -l _tools (_agents_init_install_tools "$agents_dir") + if test -n "$_tools" + set changed 1 + test $verbose -eq 1; and echo "$c_ok$_tools$c_reset" + end + + set -l _hp (git -C "$agents_dir" config --local core.hooksPath 2>/dev/null) + if test "$_hp" != .agents-tools/hooks + git -C "$agents_dir" config --local core.hooksPath .agents-tools/hooks + set changed 1 + test $verbose -eq 1; and echo "$c_ok→ Set core.hooksPath → .agents-tools/hooks$c_reset" + end + # ──────────────────────────── --agents mode ────────────────────────────── if test $do_agents -eq 1 # Detect which root-level files are real (not symlinks) @@ -248,19 +288,6 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi # ─────────────────────────── --plugins mode ────────────────────────────── if test $do_plugins -eq 1 - # Ensure AGENTS/plugins/ dirs exist with .gitkeep - for dir in "$plugins_dir" "$plugins_dir/superpowers" "$plugins_dir/plans" "$plugins_dir/specs" - if not test -d "$dir" - if not mkdir -p "$dir" - echo "$c_err""Error: could not create "(string replace "$root/" "" "$dir")"/$c_reset" >&2 - return 1 - end - set changed 1 - test $verbose -eq 1; and echo "$c_ok→ Created "(string replace "$root/" "" "$dir")"/$c_reset" - end - test -f "$dir/.gitkeep"; or touch "$dir/.gitkeep" - end - set -l docs_dir "$root/docs" if not test -d "$docs_dir" @@ -272,39 +299,146 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi test $verbose -eq 1; and echo "$c_ok→ Created docs/$c_reset" end - for plug in superpowers plans specs - set -l docs_target "$docs_dir/$plug" - set -l agents_target "$plugins_dir/$plug" + # Consolidate plans/ and specs/ directly under AGENTS/ (no plugins/ layer). + # Real source dirs from any known layout are merged into the canonical + # AGENTS/ and removed: docs/, docs/superpowers/, + # AGENTS/plugins/, AGENTS/plugins/superpowers/ (legacy). + # Symlinks: docs/superpowers/ always (superpowers skills expect it); + # docs/ only when it had existed as a real directory. + for tgt in plans specs + set -l canonical "$agents_dir/$tgt" - # If a real directory exists in docs/, migrate its contents to AGENTS/plugins/ - if test -d "$docs_target"; and not test -L "$docs_target" - set -l contents (ls -A "$docs_target" 2>/dev/null) - if test (count $contents) -gt 0 - if not command cp -rn "$docs_target/." "$agents_target/" - echo "$c_err""Error: could not copy docs/$plug → AGENTS/plugins/$plug$c_reset" >&2 - return 1 - end - end - if not rm -rf "$docs_target" - echo "$c_err""Error: could not remove docs/$plug after copy$c_reset" >&2 - return 1 - end - set changed 1 - test $verbose -eq 1; and echo "$c_ok→ Moved docs/$plug → AGENTS/plugins/$plug$c_reset" + # Remember whether docs/ was a real dir (drives its symlink below) + set -l docs_tgt_was_real 0 + if test -d "$docs_dir/$tgt"; and not test -L "$docs_dir/$tgt" + set docs_tgt_was_real 1 end - # Create symlink docs/ → ../AGENTS/plugins/ - if not test -L "$docs_target" - if not ln -s "../AGENTS/plugins/$plug" "$docs_target" - echo "$c_err""Error: could not create docs/$plug symlink$c_reset" >&2 - return 1 + test -d "$canonical"; or mkdir -p "$canonical" + + # Candidate real source dirs to merge into the canonical dir + set -l srcs "$docs_dir/$tgt" "$plugins_dir/$tgt" "$plugins_dir/superpowers/$tgt" + # Only follow docs/superpowers/ when docs/superpowers is a real + # dir; if it is a legacy symlink, its target is already covered by the + # AGENTS/plugins/superpowers/ source above. + if not test -L "$docs_dir/superpowers" + set -a srcs "$docs_dir/superpowers/$tgt" + end + + for src in $srcs + if test -d "$src"; and not test -L "$src" + set -l rel (string replace "$root/" "" "$src") + set -l contents (command ls -A "$src" 2>/dev/null) + if test (count $contents) -gt 0 + if not command cp -rn "$src/." "$canonical/" + echo "$c_err""Error: could not merge $rel → AGENTS/$tgt$c_reset" >&2 + return 1 + end + end + if not rm -rf "$src" + echo "$c_err""Error: could not remove $rel after merge$c_reset" >&2 + return 1 + end + set changed 1 + test $verbose -eq 1; and echo "$c_ok→ Merged $rel → AGENTS/$tgt$c_reset" end + end + + test -f "$canonical/.gitkeep"; or touch "$canonical/.gitkeep" + + # docs/ symlink: only recreated when it had been a real directory + set -l docs_link "$docs_dir/$tgt" + if test $docs_tgt_was_real -eq 1 + if not test -L "$docs_link" + if not ln -s "../AGENTS/$tgt" "$docs_link" + echo "$c_err""Error: could not create docs/$tgt symlink$c_reset" >&2 + return 1 + end + set changed 1 + test $verbose -eq 1; and echo "$c_ok→ Linked docs/$tgt → AGENTS/$tgt$c_reset" + end + else if test -L "$docs_link"; and test (readlink "$docs_link") != "../AGENTS/$tgt" + # Stale legacy symlink (pointed into AGENTS/plugins) → drop it + rm -f "$docs_link" set changed 1 - test $verbose -eq 1; and echo "$c_ok→ Linked docs/$plug → AGENTS/plugins/$plug$c_reset" + test $verbose -eq 1; and echo "$c_warn→ Removed stale docs/$tgt symlink$c_reset" end end - set -l _gi (_agents_init_ensure_gitignore "$root" "agents-init --plugins" "docs/superpowers" "docs/plans" "docs/specs") + # docs/superpowers/ must be a real dir holding the plans/ + specs/ symlinks. + # Replace any legacy docs/superpowers symlink (content merged above). + if test -L "$docs_dir/superpowers" + rm -f "$docs_dir/superpowers" + set changed 1 + test $verbose -eq 1; and echo "$c_warn→ Removed legacy docs/superpowers symlink$c_reset" + end + if not test -d "$docs_dir/superpowers" + if not mkdir -p "$docs_dir/superpowers" + echo "$c_err""Error: could not create docs/superpowers/$c_reset" >&2 + return 1 + end + end + + # docs/superpowers/ symlinks — always present (superpowers default) + for tgt in plans specs + set -l sp_link "$docs_dir/superpowers/$tgt" + if not test -L "$sp_link" + if not ln -s "../../AGENTS/$tgt" "$sp_link" + echo "$c_err""Error: could not create docs/superpowers/$tgt symlink$c_reset" >&2 + return 1 + end + set changed 1 + test $verbose -eq 1; and echo "$c_ok→ Linked docs/superpowers/$tgt → AGENTS/$tgt$c_reset" + else if test (readlink "$sp_link") != "../../AGENTS/$tgt" + rm -f "$sp_link" + ln -s "../../AGENTS/$tgt" "$sp_link" + set changed 1 + test $verbose -eq 1; and echo "$c_ok→ Relinked docs/superpowers/$tgt → AGENTS/$tgt$c_reset" + end + end + + # Drop the now-empty legacy AGENTS/plugins/ layer entirely + if test -d "$plugins_dir" + rm -rf "$plugins_dir" + set changed 1 + test $verbose -eq 1; and echo "$c_warn→ Removed legacy AGENTS/plugins/$c_reset" + end + + # ── AGENTS/devlogs (standard location for agent dev logs) ───────────── + set -l devlogs_dir "$agents_dir/devlogs" + set -l docs_devlogs "$docs_dir/devlogs" + + if test -d "$docs_devlogs"; and not test -L "$docs_devlogs" + # Migrate an existing docs/devlogs into AGENTS/devlogs, then symlink + test -d "$devlogs_dir"; or mkdir -p "$devlogs_dir" + set -l contents (command ls -A "$docs_devlogs" 2>/dev/null) + if test (count $contents) -gt 0 + if not command cp -rn "$docs_devlogs/." "$devlogs_dir/" + echo "$c_err""Error: could not copy docs/devlogs → AGENTS/devlogs$c_reset" >&2 + return 1 + end + end + if not rm -rf "$docs_devlogs" + echo "$c_err""Error: could not remove docs/devlogs after copy$c_reset" >&2 + return 1 + end + if not ln -s "../AGENTS/devlogs" "$docs_devlogs" + echo "$c_err""Error: could not create docs/devlogs symlink$c_reset" >&2 + return 1 + end + set changed 1 + test $verbose -eq 1; and echo "$c_ok→ Moved docs/devlogs → AGENTS/devlogs$c_reset" + else if not test -d "$devlogs_dir" + if not mkdir -p "$devlogs_dir" + echo "$c_err""Error: could not create AGENTS/devlogs$c_reset" >&2 + return 1 + end + set changed 1 + test $verbose -eq 1; and echo "$c_ok→ Created AGENTS/devlogs/$c_reset" + end + test -f "$devlogs_dir/.gitkeep"; or touch "$devlogs_dir/.gitkeep" + + set -l _gi (_agents_init_ensure_gitignore "$root" "agents-init --plugins" "docs/superpowers" "docs/plans" "docs/specs" "docs/devlogs") if test -n "$_gi" set changed 1 test $verbose -eq 1; and echo $_gi @@ -312,6 +446,11 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi end # ──────────────────────── Auto-commit AGENTS/ ──────────────────────────── + # Pull first when an upstream is configured so the local .version reflects + # any remote bumps before we add to it (no-op for local-only repos). + if git -C "$agents_dir" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1 + git -C "$agents_dir" pull --rebase --autostash -q 2>/dev/null + end git -C "$agents_dir" add -A 2>/dev/null set -l status_out (git -C "$agents_dir" status --porcelain 2>/dev/null) if test -n "$status_out" @@ -321,7 +460,8 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi set changed 1 if test $verbose -eq 1 set -l sha (git -C "$agents_dir" rev-parse --short HEAD 2>/dev/null) - echo "$c_ok→ Committed AGENTS/ ($sha) $c_dim$msg$c_reset" + set -l realmsg (git -C "$agents_dir" log -1 --pretty=%s 2>/dev/null) + echo "$c_ok→ Committed AGENTS/ ($sha) $c_dim$realmsg$c_reset" end end end diff --git a/scripts/agents-tools/hooks/pre-commit b/scripts/agents-tools/hooks/pre-commit new file mode 100755 index 0000000..ff3f604 --- /dev/null +++ b/scripts/agents-tools/hooks/pre-commit @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# agents-tools-version: 1 +# Delegates to the repo's committed .agents-tools/version-bump. Guarded so a +# missing script or failed rev-parse never blocks the commit (always exit 0). +root="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0 +[ -x "$root/.agents-tools/version-bump" ] && "$root/.agents-tools/version-bump" precommit +exit 0 diff --git a/scripts/agents-tools/hooks/prepare-commit-msg b/scripts/agents-tools/hooks/prepare-commit-msg new file mode 100755 index 0000000..57aed4a --- /dev/null +++ b/scripts/agents-tools/hooks/prepare-commit-msg @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# agents-tools-version: 1 +# Delegates to the repo's committed .agents-tools/version-bump. Guarded so a +# missing script or failed rev-parse never blocks the commit (always exit 0). +root="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0 +[ -x "$root/.agents-tools/version-bump" ] && "$root/.agents-tools/version-bump" prepmsg "$1" +exit 0 diff --git a/scripts/agents-tools/test-version-bump.sh b/scripts/agents-tools/test-version-bump.sh new file mode 100644 index 0000000..96668bb --- /dev/null +++ b/scripts/agents-tools/test-version-bump.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Self-contained tests for version-bump. Run: bash test-version-bump.sh +set -u +# Neutralize an inherited CDPATH: bash `cd` echoes the resolved path when a +# target is found via CDPATH, which would corrupt the command substitutions below. +CDPATH= +HERE="$(cd "$(dirname "$0")" && pwd)" +SCRIPT="$HERE/version-bump" +fail() { echo "FAIL: $1"; exit 1; } + +new_repo() { + local d; d="$(mktemp -d)" + git -C "$d" init -q + git -C "$d" config user.email t@t + git -C "$d" config user.name t + git -C "$d" config commit.gpgsign false + # Isolate from any global core.hooksPath so the test is hermetic. The e2e + # block re-points hooksPath at the repo's own .agents-tools/hooks. + git -C "$d" config core.hooksPath /dev/null + printf '%s\n' "$d" +} + +# ── precommit: baseline + PATCH + MINOR ────────────────────────────────────── +r="$(new_repo)"; cd "$r" +mkdir -p plans; : > plans/.gitkeep +echo 1.0.0 > .version +git add -A; "$SCRIPT" precommit; git commit -q -m init +[ "$(cat .version)" = 1.0.0 ] || fail "first commit: want 1.0.0 got $(cat .version)" + +echo hi > plans/a.md +git add -A; "$SCRIPT" precommit; git commit -q -m content +[ "$(cat .version)" = 1.0.1 ] || fail "content: want 1.0.1 got $(cat .version)" + +mkdir -p specs; : > specs/.gitkeep +git add -A; "$SCRIPT" precommit; git commit -q -m structure +[ "$(cat .version)" = 1.1.0 ] || fail "structure: want 1.1.0 got $(cat .version)" + +echo x > specs/s.md +git add -A; "$SCRIPT" precommit; git commit -q -m content2 +[ "$(cat .version)" = 1.1.1 ] || fail "content2: want 1.1.1 got $(cat .version)" + +cd /; rm -rf "$r" +echo "precommit OK" + +# ── prepmsg: subject suffix + idempotency ──────────────────────────────────── +r="$(new_repo)"; cd "$r" +echo 1.2.3 > .version +msg="$(mktemp)" +printf 'chore: do thing\n' > "$msg" +"$SCRIPT" prepmsg "$msg" +head -n1 "$msg" | grep -qF 'chore: do thing (v1.2.3)' || fail "prepmsg: subject not suffixed" +"$SCRIPT" prepmsg "$msg" +[ "$(grep -c '(v1.2.3)' "$msg")" -eq 1 ] || fail "prepmsg: not idempotent" + +# body preserved +printf 'subject\n\nbody line\n' > "$msg" +echo 2.0.0 > .version +"$SCRIPT" prepmsg "$msg" +head -n1 "$msg" | grep -qF 'subject (v2.0.0)' || fail "prepmsg: body-case subject" +grep -qF 'body line' "$msg" || fail "prepmsg: body lost" + +rm -f "$msg"; cd /; rm -rf "$r" +echo "prepmsg OK" + +# ── end-to-end via real hooks + core.hooksPath ─────────────────────────────── +r="$(new_repo)"; cd "$r" +mkdir -p .agents-tools/hooks +cp "$HERE/version-bump" .agents-tools/version-bump +cp "$HERE/hooks/pre-commit" .agents-tools/hooks/pre-commit +cp "$HERE/hooks/prepare-commit-msg" .agents-tools/hooks/prepare-commit-msg +chmod +x .agents-tools/version-bump .agents-tools/hooks/pre-commit .agents-tools/hooks/prepare-commit-msg +git config core.hooksPath .agents-tools/hooks +echo 1.0.0 > .version +mkdir -p plans; : > plans/.gitkeep +git add -A; git commit -q -m "init" +[ "$(cat .version)" = 1.0.0 ] || fail "e2e baseline: want 1.0.0 got $(cat .version)" +git log -1 --pretty=%s | grep -qF 'init (v1.0.0)' || fail "e2e baseline message: $(git log -1 --pretty=%s)" + +mkdir -p specs; : > specs/.gitkeep +git add -A; git commit -q -m "add specs" +[ "$(cat .version)" = 1.1.0 ] || fail "e2e structure: want 1.1.0 got $(cat .version)" +git log -1 --pretty=%s | grep -qF 'add specs (v1.1.0)' || fail "e2e structure message: $(git log -1 --pretty=%s)" + +echo z > specs/z.md +git add -A; git commit -q -m "edit spec" +[ "$(cat .version)" = 1.1.1 ] || fail "e2e content: want 1.1.1 got $(cat .version)" +git log -1 --pretty=%s | grep -qF 'edit spec (v1.1.1)' || fail "e2e content message: $(git log -1 --pretty=%s)" + +cd /; rm -rf "$r" +echo "e2e OK" + +# ── corrupt .version recovers to a clean baseline ──────────────────────────── +r="$(new_repo)"; cd "$r" +mkdir -p plans; : > plans/.gitkeep +echo 1.0.0 > .version +git add -A; "$SCRIPT" precommit; git commit -q -m init +printf '1.\n' > .version # partial/corrupt +echo hi > plans/a.md +git add -A; "$SCRIPT" precommit +[ "$(cat .version)" = 1.0.1 ] || fail "corrupt recovery: want 1.0.1 got $(cat .version)" +cd /; rm -rf "$r" +echo "corrupt-recovery OK" + +# ── hook never blocks a commit when version-bump is missing ────────────────── +r="$(new_repo)"; cd "$r" +mkdir -p .agents-tools/hooks +cp "$HERE/hooks/pre-commit" .agents-tools/hooks/pre-commit +cp "$HERE/hooks/prepare-commit-msg" .agents-tools/hooks/prepare-commit-msg +chmod +x .agents-tools/hooks/pre-commit .agents-tools/hooks/prepare-commit-msg +# note: .agents-tools/version-bump intentionally NOT copied +git config core.hooksPath .agents-tools/hooks +echo hi > file.txt +git add -A +git commit -q -m "no bumper present" || fail "missing version-bump blocked the commit" +git rev-parse -q --verify HEAD >/dev/null || fail "commit did not happen" +cd /; rm -rf "$r" +echo "hook-resilience OK" diff --git a/scripts/agents-tools/version-bump b/scripts/agents-tools/version-bump new file mode 100755 index 0000000..72a950a --- /dev/null +++ b/scripts/agents-tools/version-bump @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# agents-tools-version: 1 +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# version-bump [msgfile] +# +# Maintains /.version (MAJOR.MINOR.PATCH). MAJOR is manual-only. +# MINOR bumps when the tracked directory set changes between HEAD and the +# staged index; PATCH bumps on any other commit. Never blocks a commit: +# all hook paths exit 0 even on unexpected errors. + +mode="${1:-}" +root="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0 +vfile="$root/.version" + +read_dirset() { + # $1: HEAD|INDEX — print sorted unique content dirs (dot-dirs excluded) + local listing + if [ "$1" = HEAD ]; then + listing="$(git ls-tree -r --name-only HEAD 2>/dev/null || true)" + else + listing="$(git ls-files 2>/dev/null || true)" + fi + printf '%s\n' "$listing" \ + | while IFS= read -r f; do [ -n "$f" ] && dirname "$f"; done \ + | { grep -v '^\.' || true; } \ + | sort -u +} + +bump_precommit() { + [ -f "$vfile" ] || printf '1.0.0\n' > "$vfile" + + # First commit: establish the baseline, no bump. + if ! git rev-parse --verify -q HEAD >/dev/null 2>&1; then + grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' "$vfile" || printf '1.0.0\n' > "$vfile" + git add -- "$vfile" 2>/dev/null || true + return 0 + fi + + local ver major minor patch + ver="$(head -n1 "$vfile")" + if printf '%s' "$ver" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + IFS=. read -r major minor patch <<< "$ver" + else + # Corrupt/partial .version (e.g. "1." or "abc") → reset to a clean baseline. + major=1; minor=0; patch=0 + fi + + if [ "$(read_dirset HEAD)" != "$(read_dirset INDEX)" ]; then + minor=$((minor + 1)); patch=0 + else + patch=$((patch + 1)) + fi + printf '%s.%s.%s\n' "$major" "$minor" "$patch" > "$vfile" + git add -- "$vfile" 2>/dev/null || true +} + +bump_prepmsg() { + local msgfile="${1:-}" version first rest + [ -n "$msgfile" ] && [ -f "$msgfile" ] || return 0 + [ -f "$vfile" ] || return 0 + version="$(head -n1 "$vfile")" + [ -n "$version" ] || return 0 + head -n1 "$msgfile" | grep -qF "(v$version)" && return 0 + first="$(head -n1 "$msgfile")" + rest="$(tail -n +2 "$msgfile")" + if [ -n "$rest" ]; then + { printf '%s (v%s)\n' "$first" "$version"; printf '%s\n' "$rest"; } > "$msgfile" + else + printf '%s (v%s)\n' "$first" "$version" > "$msgfile" + fi +} + +case "$mode" in + precommit) bump_precommit ;; + prepmsg) bump_prepmsg "${2:-}" ;; + *) echo "version-bump: unknown mode '$mode'" >&2; exit 2 ;; +esac +exit 0