antrophiclove
← Kütüphaneye dön
0

Openspec auto archive

Her git işlemi öncesinde, arşivler OpenSpec değişikliklerini tamamlar (tüm görevler [x]) ve bunları aynı işleme tabi tutar; openspec arşivi başarısız olursa, işlemi engeller ve Claude'dan opsx:archive'ı çağırmasını ister, ardından yeniden deneyin

yazan kuscubasiece·8 Eylül 2026

Kaynak: davepoon/claude-code-subagents-collection · MIT

Kurulum
curl -sL https://www.antrophiclove.com/api/kaynak/openspec-auto-archive/raw

Bu içerik mevcut dosyaya elle harmanlanmalı: .claude/settings.json

openspec-auto-archive

Keep your OpenSpec workspace tidy automatically. This PreToolUse(Bash) hook inspects every git commit command before it runs. It scans openspec/changes/ for changes whose tasks.md is fully complete (all checkboxes - [x], none - [ ]), runs openspec archive <name> -y for each, and stages the archived paths (openspec/changes and openspec/specs) so the archive lands in the same commit.

If openspec archive fails for any change, the hook blocks the commit (permission deny) and returns guidance to Claude: invoke the opsx:archive skill for each failed change, then retry the commit.

The git commit detection tokenizes the command with xargs -n1 (no execution) and matches a real git command followed by the commit subcommand, so strings like git log --grep=commit, git show commit, or echo git commit do not trigger it. Change names are allowlisted (^[A-Za-z0-9][A-Za-z0-9._-]*$) before use, so a leading - (option injection) or odd characters are skipped. Changes without checkboxes in tasks.md, changes with unfinished tasks, and the archive/ directory itself are skipped. If jq/openspec are not in PATH or the repo has no openspec/changes/ directory, the hook silently passes through and never blocks.

Event Configuration

  • Event Type: PreToolUse
  • Tool Matcher: Bash
  • Category: git

Environment Variables

  • None

Requirements

  • jq (parse the hook payload)
  • git and xargs (standard on macOS and Linux)
  • openspec CLI in PATH (if missing, the hook silently skips — it does not block commits)
  • An OpenSpec-initialized repository (openspec/changes/ directory)

Script

#!/usr/bin/env bash
# openspec-auto-archive — PreToolUse(Bash) hook.
# Before `git commit`, archives completed OpenSpec changes (all tasks [x],
# none [ ]) and stages them so the archive goes into the same commit. If
# `openspec archive` fails, blocks the commit and asks Claude to invoke
# opsx:archive, then retry the commit.
set -uo pipefail

input=$(cat)
command -v jq >/dev/null 2>&1 || exit 0
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
[ -n "$cmd" ] || exit 0

# --- Match a real `git commit`, not a substring -----------------------------
# Tokenize respecting shell quoting WITHOUT executing (xargs does not exec),
# then require `git` in command position followed by the `commit` subcommand.
# This ignores `git log --grep=commit`, `git show commit`, `echo git commit`.
toks=(); while IFS= read -r _t; do toks+=("$_t"); done < <(printf '%s' "$cmd" | xargs -n1 2>/dev/null)
[ "${#toks[@]}" -gt 0 ] || exit 0

is_commit=0; cmd_pos=1; n=${#toks[@]}; i=0
while [ $i -lt $n ]; do
  # A command starts at the first token and after a shell separator.
  case "${toks[$i]}" in
    ';'|'&&'|'||'|'|'|'&') cmd_pos=1; i=$((i+1)); continue;;
  esac
  if [ "$cmd_pos" = 1 ] && [ "${toks[$i]}" = "git" ]; then
    j=$((i+1))
    while [ $j -lt $n ]; do  # skip git global options, find the subcommand
      case "${toks[$j]}" in
        -C|-c|--git-dir|--work-tree|--namespace) j=$((j+2));;  # option + value
        -*) j=$((j+1));;                                        # other global flag
        commit) is_commit=1; break;;
        *) break;;                                             # subcommand is not commit
      esac
    done
    [ "$is_commit" = 1 ] && break
  fi
  cmd_pos=0; i=$((i+1))
done
[ "$is_commit" -eq 1 ] || exit 0

# --- Archive completed changes ----------------------------------------------
root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
cd "$root" || exit 0
command -v openspec >/dev/null 2>&1 || exit 0
changes_dir="openspec/changes"
[ -d "$changes_dir" ] || exit 0

failed=""  # comma-joined list of allowlisted change names that failed to archive
for dir in "$changes_dir"/*/; do
  name=$(basename "$dir")
  [ "$name" = "archive" ] && continue
  # Allowlist change names: ^[A-Za-z0-9][A-Za-z0-9._-]*$ — first char alnum, so
  # a leading `-` (option injection) and any odd characters are skipped.
  case "$name" in
    [!A-Za-z0-9]* | *[!A-Za-z0-9._-]*) continue;;
  esac

  tasks="$dir/tasks.md"
  [ -f "$tasks" ] || continue
  unchecked=$(grep -cE '^[[:space:]]*- \[ \]' "$tasks" || true)
  checked=$(grep -cE '^[[:space:]]*- \[[xX]\]' "$tasks" || true)
  [ "$((unchecked + checked))" -gt 0 ] || continue  # no checkboxes — not that kind of change
  [ "$unchecked" -eq 0 ] || continue                # unfinished tasks — skip

  # Archive with validation (no --no-validate); -y — no interactive prompts.
  # Name is allowlisted above, so it cannot be read as an option.
  if openspec archive "$name" -y >/dev/null 2>&1; then
    # Stage only what archiving touches — the changes/ subtree (moved change +
    # archive/ destination) and updated specs — not a blanket `git add -A
    # openspec`, which could also grab unrelated files at the openspec root.
    git add -- "$changes_dir" "openspec/specs" >/dev/null 2>&1 || true
  else
    failed="${failed:+$failed, }$name"
  fi
done

# Success (or nothing to archive) → allow the commit; the archive is staged.
[ -z "$failed" ] && exit 0

# Failure → block. Fixed message + allowlisted change names only; no raw CLI
# output is passed into Claude's context (avoids indirect prompt injection).
reason="OpenSpec: 'openspec archive' failed for completed change(s): $failed. \
Run the opsx:archive skill for each, then retry git commit — the archive must \
go into this same commit (git add the archived openspec paths)."

jq -n --arg r "$reason" '{
  hookSpecificOutput: {
    hookEventName: "PreToolUse",
    permissionDecision: "deny",
    permissionDecisionReason: $r
  }
}'
exit 0