feat(agents): AGENTS/ structure versioning + plans/specs/devlogs consolidation #57

Merged
rootiest merged 7 commits from feat/agents-version-tracking into main 2026-06-18 02:10:09 +00:00
9 changed files with 481 additions and 53 deletions
+5
View File
@@ -293,3 +293,8 @@ pyrightconfig.json
# agents-init --agents
AGENTS/
# ────────────────────────────────────────────────────────
# ──────────────── Added by agents-init ──────────────────
# agents-init --plugins
docs/devlogs
# ────────────────────────────────────────────────────────
+8
View File
@@ -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
+24 -11
View File
@@ -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/<tgt>), 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
+48
View File
@@ -0,0 +1,48 @@
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
# SYNOPSIS
# _agents_init_install_tools <agents_dir>
#
# DESCRIPTION
# Copies the canonical version-bump script and git hook shims from
# fish-config's scripts/agents-tools/ into <agents_dir>/.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
+182 -42
View File
@@ -17,7 +17,27 @@
# or symlink → AGENTS.md (single-source case)
# <root>/AGENTS.md → AGENTS/AGENTS.md
# <root>/CLAUDE.md → AGENTS/CLAUDE.md
# docs/<plug> → ../AGENTS/plugins/<plug>
# 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/<tgt>,
# docs/superpowers/<tgt>, and the old AGENTS/plugins/ layout) into the
# canonical AGENTS/<tgt>; 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/<tgt> and removed: docs/<tgt>, docs/superpowers/<tgt>,
# AGENTS/plugins/<tgt>, AGENTS/plugins/superpowers/<tgt> (legacy).
# Symlinks: docs/superpowers/<tgt> always (superpowers skills expect it);
# docs/<tgt> 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/<tgt> 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/<plug> → ../AGENTS/plugins/<plug>
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/<tgt> when docs/superpowers is a real
# dir; if it is a legacy symlink, its target is already covered by the
# AGENTS/plugins/superpowers/<tgt> 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/<tgt> 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/<tgt> 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
+7
View File
@@ -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
+7
View File
@@ -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
+120
View File
@@ -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"
+80
View File
@@ -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 <precommit|prepmsg> [msgfile]
#
# Maintains <repo>/.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