Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"plugins": [
{
"name": "vstack",
"version": "1.67.0",
"version": "1.68.0",
"source": "./claude",
"description": "28 skills that fire without a slash command, 14 agents, 15 commands, and the session hook that routes situations to skills. Most skills are ported from pstack and Superpowers \u2014 see claude/skills/ATTRIBUTION.md for per-skill source and license.",
"category": "workflow"
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ Versions follow [semver](https://semver.org). The version lives in two manifests
`.claude-plugin/marketplace.json` and `claude/.claude-plugin/plugin.json`, and check 13 of
`.claude/verify.sh` fails when they disagree.

## 1.68.0 — 2026-09-03

- **`overlay.sh --check` reports drift without writing.** Every file the write path would
touch is compared against the payload and reported as `stale`, `absent`, `legacy` or
`differs (repo-owned)`; exit 1 on anything the next `overlay` run would change. Overlaid repos
can now learn that the template moved. (#10)
- **`tests/inventory-contract.sh --write` re-points the inventory.** Replaces the hand-run jq
after every payload commit; refuses on a dirty tree, re-validates, prints the commit line, and
is a no-op when the contract already holds. (#9)

## 1.67.0 — 2026-09-02

- **A security toolchain ships with the overlay.** `claude/security-scan.sh` runs gitleaks,
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,14 @@ only lane a cloud sandbox without your home directory can reach):
| `claude/security.yml.tmpl` | `.github/workflows/security.yml` | seeded if absent |
| `claude/dependabot.yml.tmpl` | `.github/dependabot.yml` | seeded if absent |

A repo overlaid at commit X keeps that commit's hooks, agents, commands, skills and scanner
until someone re-runs overlay — nothing else reports the staleness. `vstack overlay --check
<repo>` (or `./overlay.sh --check <repo>`) diffs the destination against the "always
overwritten" file list above plus the two seeded templates and the `.conductor/settings.toml`
pin, writes nothing, and prints one line per drifted file followed by `overlay --check: N
stale, M repo-owned diffs, K missing`. Exit 0 only when N and K are both 0; repo-owned template
edits (M) never fail the check.

## Day to day

| Command | What it does |
Expand Down
4 changes: 4 additions & 0 deletions bin/vstack
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ commands:
install [args...] run the repo's install.sh (args passed through, e.g. --dry-run)
doctor [args...] run ~/.config/agents/bin/doctor (args passed through, e.g. --drift)
overlay <repo> run the repo's overlay.sh against <repo>
overlay --check <repo>
read-only: report drift against a repo already overlaid (stale
copies, repo-owned template edits, missing files, conductor pin
behind), write nothing, exit 1 if any file is stale or missing
verify run the repo's .claude/verify.sh
test run tests/auto-trigger.sh (live skill-firing regression; needs claude auth, ~15 min)
trust [repo-dir] [--yes]
Expand Down
2 changes: 1 addition & 1 deletion claude/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "vstack",
"version": "1.67.0",
"version": "1.68.0",
"description": "Skills that fire on the situation instead of a slash command, plus the subagents, commands, and session hook that make them fire. Verification gates, parallel fan-out, code review, and writing discipline.",
"author": {
"name": "Vedant Kumar"
Expand Down
6 changes: 3 additions & 3 deletions claude/inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@
"contract_version": 1,
"product": {
"name": "vstack",
"version": "1.67.0",
"version": "1.68.0",
"version_source": [
"claude/.claude-plugin/plugin.json",
".claude-plugin/marketplace.json"
],
"repository": "https://github.com/itsvedantkumar/vstack"
},
"derived_at": {
"head": "4d0166f635f1e8647363b1eee769191512b9f547",
"payload_digest": "61967c2a5347ad45701ae581b99db1e624832a5144d7af58d2ec4756e3150ac8",
"head": "96b94950accb42a31c97c9ff17e4c325f5dfe77a",
"payload_digest": "f615cff0d365b6e010c19b4b24de68424c72f4f6705646b866b10593f603e306",
"digest_recipe_source": "tests/inventory-contract.sh:payload_digest_compute()",
"digest_recipe_note": "payload_digest hashes the working-tree BYTES of every payload file -- tracked and untracked-but-not-ignored -- together with its executable bit and path, then hashes that listing. Recompute it with `tests/inventory-contract.sh --print-digest` and nothing else: typing a recipe by hand into a shell is a second implementation. This field used to hold the recipe as an executable string that the validator eval'd, on the reasoning that the two could then never disagree. That made the artifact its own oracle -- editing the recipe and the digest together passed while measuring nothing -- and it also hashed index blob ids plus `git status --porcelain`, which names files and status letters but never their contents, so two different unstaged edits to the same path produced the same digest. Both were confirmed by running them.",
"staleness_is_the_signal": "This file is a snapshot. When the payload digest no longer matches, the validator regenerates every derived field and diffs. A mismatch is the intended failure, not a maintenance chore: it means a component moved and at least one consumer has not been told.",
Expand Down
141 changes: 139 additions & 2 deletions overlay.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,155 @@
# `.claude/` overlay is the ONLY config lane that reaches it. Run this in every repo you
# dispatch work to from your phone.
#
# Usage: ./overlay.sh [target-repo-dir] (default: $PWD)
# Usage: ./overlay.sh [--check] [target-repo-dir] (default: $PWD)
# --check: report drift against a repo already overlaid, write nothing, exit 1 if stale
set -euo pipefail

SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEST="${1:-$PWD}"

# --check may come before or after the dest arg (`overlay.sh --check repo` and the vstack
# dispatcher's `overlay <repo> --check` both need to work), so it is pulled out of the
# positional list rather than assumed to be $1. Every other positional arg — today there is
# only ever one — passes through untouched, which is what keeps a plain `./overlay.sh <repo>`
# byte-identical to before this flag existed.
CHECK=0
ARGS=()
for a in "$@"; do
case "$a" in
--check) CHECK=1 ;;
*) ARGS+=("$a") ;;
esac
done
DEST="${ARGS[0]:-$PWD}"

# -e, not -d: inside a git worktree .git is a file pointing at the real git dir. The -d test
# rejected every Conductor workspace, which is precisely where this needs to run — Conductor
# lays them out as workspaces/<project>/<workspace>, and each one is a worktree.
[ -e "$DEST/.git" ] || { echo "error: $DEST is not a git repo or worktree" >&2; exit 1; }
[ -f "$SRC/claude/settings.json" ] || { echo "error: run from the vstack repo" >&2; exit 1; }

# --check stops here, before anything below that writes. It walks the exact same file lists
# overlay would copy — unconditional copies, seed_tmpl targets, the conductor pin — but only
# ever reads, so a repo can be checked in CI or a cron without risking the write path neither
# of those callers reviewed.
if [ "$CHECK" -eq 1 ]; then
STALE=0
OWNED=0
MISSING=0

# <label> <src file> <dest file> — the "always overwritten" half of overlay: hooks, agents,
# commands, skills' loose files, policy.md, statusline.sh, security-scan.sh. A repo where
# these differ from $SRC has been overlaid at an older commit and silently kept running it;
# cmp -s is the same byte comparison seed_tmpl already uses below to decide "kept" vs "wrote".
check_unconditional() {
local label="$1" s="$2" d="$3"
if [ ! -f "$d" ]; then
echo "missing $label (not present — overlay would write it)"
MISSING=$((MISSING + 1))
elif ! cmp -s "$s" "$d"; then
echo "stale $label (differs from $s — overlay would refresh it)"
STALE=$((STALE + 1))
fi
}

for f in "$SRC"/claude/hooks/*.sh; do
[ -e "$f" ] || continue
b=$(basename "$f")
check_unconditional ".claude/hooks/$b" "$f" "$DEST/.claude/hooks/$b"
done
for f in "$SRC"/claude/agents/*.md; do
[ -e "$f" ] || continue
b=$(basename "$f")
check_unconditional ".claude/agents/$b" "$f" "$DEST/.claude/agents/$b"
done
for f in "$SRC"/claude/agents/reference/*.ref; do
[ -e "$f" ] || continue
b=$(basename "$f")
check_unconditional ".claude/agents/reference/$b" "$f" "$DEST/.claude/agents/reference/$b"
done
for f in "$SRC"/claude/commands/*.md; do
[ -e "$f" ] || continue
b=$(basename "$f")
check_unconditional ".claude/commands/$b" "$f" "$DEST/.claude/commands/$b"
done
check_unconditional ".claude/hooks/policy.md" "$SRC/claude/CLAUDE.md" "$DEST/.claude/hooks/policy.md"
check_unconditional ".claude/statusline.sh" "$SRC/claude/statusline.sh" "$DEST/.claude/statusline.sh"
check_unconditional ".claude/security-scan.sh" "$SRC/claude/security-scan.sh" "$DEST/.claude/security-scan.sh"

# Skills carry references/ and scripts/ subtrees and overlay replaces each whole (rm -rf then
# cp -R), so a single-file cmp is not enough — diff -rq walks the tree the same way the write
# path does.
for d in "$SRC"/claude/skills/*/; do
[ -d "$d" ] || continue
s=$(basename "$d")
if [ ! -d "$DEST/.claude/skills/$s" ]; then
echo "missing .claude/skills/$s (not present — overlay would write it)"
MISSING=$((MISSING + 1))
elif ! diff -rq "${d%/}" "$DEST/.claude/skills/$s" >/dev/null 2>&1; then
echo "stale .claude/skills/$s (differs from $SRC/claude/skills/$s — overlay would refresh it)"
STALE=$((STALE + 1))
fi
done

# <label> <src tmpl> <dest file> — seed_tmpl's targets. These are seeded once and never
# overwritten, so a difference here is not staleness, it is the repo's own edit: report it as
# repo-owned and leave it out of the exit-code gate.
check_seeded() {
local label="$1" s="$2" d="$3"
if [ ! -f "$d" ]; then
echo "absent $label (never seeded — overlay would write it)"
MISSING=$((MISSING + 1))
elif ! cmp -s "$s" "$d"; then
echo "differs (repo-owned) $label — diff it against $s if you want"
OWNED=$((OWNED + 1))
fi
}
check_seeded ".github/workflows/security.yml" "$SRC/claude/security.yml.tmpl" "$DEST/.github/workflows/security.yml"
check_seeded ".github/dependabot.yml" "$SRC/claude/dependabot.yml.tmpl" "$DEST/.github/dependabot.yml"
# Same seed-once contract as the two above, easy to miss because their write-path calls
# (~:260 for CLAUDE.md, ~:266-278 for verify.sh) aren't seed_tmpl itself — CLAUDE.md.tmpl is
# a plain `[ -f ] || cp`, and verify.sh's write branch adds a chmod and a "kept + hint" path
# instead of seed_tmpl's "kept (differs from template)" — but the seeded-once semantics are
# identical: never overwritten, so absent here is real missing-file drift, not a repo-owned
# diff, since content drift from either template is expected and deliberate.
check_seeded "CLAUDE.md" "$SRC/CLAUDE.md.tmpl" "$DEST/CLAUDE.md"
check_seeded ".claude/verify.sh" "$SRC/claude/verify.sh.tmpl" "$DEST/.claude/verify.sh"

# Legacy migration: overlay's write path (~:230-232) deletes a tracked .claude/CLAUDE.md the
# moment it finds one, because its existence IS the duplication policy.md was written to
# replace. --check has no write path, so it reports instead of removing — a repo still
# carrying this file has not run overlay since the migration and is drifted exactly like a
# stale copy, so it counts toward the same exit-code gate.
if [ -f "$DEST/.claude/CLAUDE.md" ]; then
echo "legacy .claude/CLAUDE.md (overlay would remove it)"
STALE=$((STALE + 1))
fi

# Conductor pin: same substring overlay itself rewrites, read instead of written. Compared
# against $SRC's own current HEAD, not a fetched origin/main — that is the commit overlay
# would pin to if run right now, on whatever branch $SRC happens to be checked out to, which
# is exactly what "stale" needs to mean here.
if [ -f "$DEST/.conductor/settings.toml" ]; then
curpin=$(grep -oE '/vstack/[0-9a-f]{40}/bootstrap\.sh' "$DEST/.conductor/settings.toml" 2>/dev/null | head -1 || true)
curpin=${curpin#/vstack/}; curpin=${curpin%/bootstrap.sh}
if [ -n "$curpin" ]; then
mainsha=$(git -C "$SRC" rev-parse HEAD)
echo "pin $curpin vs main $mainsha"
[ "$curpin" = "$mainsha" ] || STALE=$((STALE + 1))
fi
else
echo "missing .conductor/settings.toml (not present — overlay would write it)"
MISSING=$((MISSING + 1))
fi

echo "overlay --check: $STALE stale, $OWNED repo-owned diffs, $MISSING missing"
if [ "$STALE" -eq 0 ] && [ "$MISSING" -eq 0 ]; then
exit 0
else
exit 1
fi
fi

mkdir -p "$DEST/.claude/hooks" "$DEST/.claude/agents" "$DEST/.claude/agents/reference" "$DEST/.claude/commands" "$DEST/.claude/skills"

# settings.json: ship the project-safe subset, merge it, don't clobber the repo's own keys.
Expand Down
Loading