init version
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
#!/bin/bash
|
||||
# comet-hook-guard.sh — PreToolUse hook for Comet phase enforcement
|
||||
#
|
||||
# Blocks file writes (Write/Edit) when the active Comet change is in
|
||||
# a phase that does not allow source code modifications (open/design/archive).
|
||||
#
|
||||
# Usage (called by harness, not directly):
|
||||
# PreToolUse matcher "Write|Edit" → this script
|
||||
# Stdin: JSON {"tool_name":"Write|Edit","tool_input":{"file_path":"..."}}
|
||||
# Exit 0 = allow
|
||||
# Exit 2 = blocked (stderr message shown to user)
|
||||
#
|
||||
# Cross-platform: macOS / Linux / Windows Git Bash
|
||||
# shellcheck disable=SC2329
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Extract target file path ──────────────────────────────────────
|
||||
|
||||
TARGET=""
|
||||
|
||||
# Method 1: FILE_PATH environment variable (set by some harnesses)
|
||||
if [ -n "${FILE_PATH:-}" ]; then
|
||||
TARGET="$FILE_PATH"
|
||||
fi
|
||||
|
||||
# Method 2: Parse stdin JSON
|
||||
if [ -z "$TARGET" ]; then
|
||||
INPUT=""
|
||||
if [ ! -t 0 ]; then
|
||||
INPUT=$(cat 2>/dev/null || true)
|
||||
fi
|
||||
if [ -n "$INPUT" ]; then
|
||||
# Extract file_path value — works for both Write and Edit tool inputs
|
||||
TARGET=$(printf '%s' "$INPUT" \
|
||||
| grep -oE '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' 2>/dev/null \
|
||||
| head -1 \
|
||||
| sed 's/^"file_path"[[:space:]]*:[[:space:]]*"//' \
|
||||
| sed 's/"$//' \
|
||||
|| true)
|
||||
fi
|
||||
fi
|
||||
|
||||
# No target found — allow (not a file-path-bearing operation)
|
||||
if [ -z "$TARGET" ]; then
|
||||
echo "[COMET-HOOK] allowed: no file path in tool input" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Normalize to forward slashes, collapse doubles from JSON escaping (\\ → //)
|
||||
TARGET=$(printf '%s' "$TARGET" | sed 's|\\|/|g' | sed 's|///*|/|g')
|
||||
|
||||
# ── Resolve to project-relative path ─────────────────────────────
|
||||
|
||||
# Normalize helper: forward slashes only
|
||||
norm() { printf '%s' "$1" | sed 's|\\|/|g'; }
|
||||
|
||||
RELPATH=$(norm "$TARGET")
|
||||
|
||||
# If already relative, use as-is
|
||||
case "$RELPATH" in
|
||||
/*|[A-Za-z]:/*)
|
||||
# Absolute — try stripping CWD prefixes
|
||||
CWD_UNIX=$(norm "$(pwd)")
|
||||
CWD_PHYS=$(norm "$(pwd -P 2>/dev/null || pwd)")
|
||||
|
||||
# Try: TARGET as-is vs CWD logical
|
||||
if [ "${RELPATH#"$CWD_UNIX"/}" != "$RELPATH" ]; then
|
||||
RELPATH="${RELPATH#"$CWD_UNIX"/}"
|
||||
# Try: TARGET as-is vs CWD physical (macOS /var → /private/var)
|
||||
elif [ "${RELPATH#"$CWD_PHYS"/}" != "$RELPATH" ]; then
|
||||
RELPATH="${RELPATH#"$CWD_PHYS"/}"
|
||||
else
|
||||
# Resolve TARGET's parent through filesystem (handles symlinked TARGET path)
|
||||
_PDIR=$(cd "$(dirname "$TARGET")" 2>/dev/null && pwd -P 2>/dev/null || true)
|
||||
if [ -n "$_PDIR" ]; then
|
||||
_TRESOLVED=$(norm "${_PDIR}/$(basename "$TARGET")")
|
||||
if [ "${_TRESOLVED#"$CWD_UNIX"/}" != "$_TRESOLVED" ]; then
|
||||
RELPATH="${_TRESOLVED#"$CWD_UNIX"/}"
|
||||
elif [ "${_TRESOLVED#"$CWD_PHYS"/}" != "$_TRESOLVED" ]; then
|
||||
RELPATH="${_TRESOLVED#"$CWD_PHYS"/}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Helpers to read .comet.yaml fields ───────────────────────────
|
||||
|
||||
is_archived() {
|
||||
grep "^archived:" "$1" 2>/dev/null \
|
||||
| awk '{print $2}' | tr -d '[:space:][:cntrl:]' || true
|
||||
}
|
||||
|
||||
read_phase() {
|
||||
grep "^phase:" "$1" 2>/dev/null \
|
||||
| awk '{print $2}' | tr -d '[:space:][:cntrl:]' || true
|
||||
}
|
||||
|
||||
read_field() {
|
||||
grep "^$1:" "$2" 2>/dev/null \
|
||||
| head -1 | awk '{print $2}' | tr -d '[:space:][:cntrl:]' || true
|
||||
}
|
||||
|
||||
# ── Determine the governing Comet change + phase ─────────────────
|
||||
#
|
||||
# A write targeting a specific change directory (openspec/changes/<name>/...)
|
||||
# must be governed by THAT change's own phase — never by an unrelated
|
||||
# active change. Otherwise a change left in the `archive` phase would
|
||||
# wrongly block artifact writes for a brand-new change created alongside it.
|
||||
|
||||
PHASE=""
|
||||
# Path to the .comet.yaml that governs this write (used for deeper invariant checks)
|
||||
GOV_YAML=""
|
||||
|
||||
case "$RELPATH" in
|
||||
openspec/changes/*/*)
|
||||
_rest="${RELPATH#openspec/changes/}"
|
||||
_own_change="${_rest%%/*}"
|
||||
if [ -n "$_own_change" ] && [ "$_own_change" != "archive" ]; then
|
||||
_own_yaml="openspec/changes/${_own_change}/.comet.yaml"
|
||||
if [ -f "$_own_yaml" ]; then
|
||||
if [ "$(is_archived "$_own_yaml")" = "true" ]; then
|
||||
# This change is already archived — its own writes are unrestricted
|
||||
echo "[COMET-HOOK] allowed: $RELPATH (own change archived)" >&2
|
||||
exit 0
|
||||
fi
|
||||
PHASE=$(read_phase "$_own_yaml")
|
||||
GOV_YAML="$_own_yaml"
|
||||
else
|
||||
# Change directory exists but state file not yet written
|
||||
# (artifacts are created before .comet.yaml during /comet-open).
|
||||
# Treat as `open` so proposal/design/tasks/specs are allowed.
|
||||
PHASE="open"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Fallback: writes outside a specific change directory are governed by
|
||||
# the first active (non-archived) change.
|
||||
if [ -z "$PHASE" ]; then
|
||||
YAML_FILE=""
|
||||
if [ -d "openspec/changes" ]; then
|
||||
for dir in openspec/changes/*/; do
|
||||
[ -d "$dir" ] || continue
|
||||
# Skip archived changes directory
|
||||
case "$dir" in
|
||||
*/archive/*) continue ;;
|
||||
esac
|
||||
if [ -f "${dir}.comet.yaml" ]; then
|
||||
# Skip changes already marked as archived
|
||||
if [ "$(is_archived "${dir}.comet.yaml")" = "true" ]; then
|
||||
continue
|
||||
fi
|
||||
YAML_FILE="${dir}.comet.yaml"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# No active change — allow all writes
|
||||
if [ -z "$YAML_FILE" ]; then
|
||||
echo "[COMET-HOOK] allowed: no active comet change" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PHASE=$(read_phase "$YAML_FILE")
|
||||
GOV_YAML="$YAML_FILE"
|
||||
fi
|
||||
|
||||
if [ -z "$PHASE" ]; then
|
||||
echo "[COMET-HOOK] allowed: no phase in .comet.yaml" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Whitelist: phase-aware allowed paths ─────────────────────────
|
||||
|
||||
case "$RELPATH" in
|
||||
openspec/*)
|
||||
# OpenSpec artifacts — phase-aware sub-check
|
||||
case "$PHASE" in
|
||||
open)
|
||||
# open: allow proposal, design, tasks, yaml, handoff, specs
|
||||
case "$RELPATH" in
|
||||
*/proposal.md|*/design.md|*/tasks.md|*/.openspec.yaml|*/.comet.yaml|*/.comet/*|*/specs/*)
|
||||
echo "[COMET-HOOK] allowed: $RELPATH (phase: open, openspec artifacts)" >&2
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
design)
|
||||
# design: allow handoff, delta spec (Spec Patch), proposal/design/tasks (minor refinements), .comet.yaml
|
||||
case "$RELPATH" in
|
||||
*/proposal.md|*/design.md|*/tasks.md|*/.comet/*|*/specs/*|*/.comet.yaml|*/.openspec.yaml)
|
||||
echo "[COMET-HOOK] allowed: $RELPATH (phase: design, handoff/spec)" >&2
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
build)
|
||||
# build: allow delta spec (incremental update), tasks, .comet.yaml
|
||||
case "$RELPATH" in
|
||||
*/specs/*|*/tasks.md|*/.comet.yaml|*/.openspec.yaml)
|
||||
echo "[COMET-HOOK] allowed: $RELPATH (phase: build, spec/tasks)" >&2
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
verify)
|
||||
# verify: allow tasks (post-check), .comet.yaml
|
||||
case "$RELPATH" in
|
||||
*/tasks.md|*/.comet.yaml|*/.openspec.yaml)
|
||||
echo "[COMET-HOOK] allowed: $RELPATH (phase: verify, tasks/state)" >&2
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
archive)
|
||||
# archive: allow .comet.yaml state updates only
|
||||
case "$RELPATH" in
|
||||
*/.comet.yaml|*/.openspec.yaml)
|
||||
echo "[COMET-HOOK] allowed: $RELPATH (phase: archive, state)" >&2
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
docs/superpowers/*)
|
||||
# Superpowers artifacts — phase-aware sub-check
|
||||
case "$PHASE" in
|
||||
design)
|
||||
echo "[COMET-HOOK] allowed: $RELPATH (phase: design, superpowers)" >&2
|
||||
exit 0
|
||||
;;
|
||||
build)
|
||||
echo "[COMET-HOOK] allowed: $RELPATH (phase: build, superpowers)" >&2
|
||||
exit 0
|
||||
;;
|
||||
verify)
|
||||
echo "[COMET-HOOK] allowed: $RELPATH (phase: verify, superpowers)" >&2
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
# open/archive: block docs/superpowers writes
|
||||
;;
|
||||
.comet/*|*/.comet/*)
|
||||
# Comet config
|
||||
echo "[COMET-HOOK] allowed: $RELPATH (whitelist: comet config)" >&2
|
||||
exit 0
|
||||
;;
|
||||
.claude/*)
|
||||
# Claude settings/rules
|
||||
echo "[COMET-HOOK] allowed: $RELPATH (whitelist: claude config)" >&2
|
||||
exit 0
|
||||
;;
|
||||
CLAUDE.md|CHANGELOG.md|README.md|*.md)
|
||||
# Root-level markdown files
|
||||
case "$RELPATH" in
|
||||
*/*) ;; # subdirectory .md — NOT whitelisted, fall through
|
||||
*)
|
||||
echo "[COMET-HOOK] allowed: $RELPATH (whitelist: root markdown)" >&2
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
.comet.yaml|comet.yaml|.comet.yml|comet.yml)
|
||||
# Project-level comet config
|
||||
echo "[COMET-HOOK] allowed: $RELPATH (whitelist: comet config)" >&2
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Phase-based enforcement ──────────────────────────────────────
|
||||
|
||||
case "$PHASE" in
|
||||
build|verify)
|
||||
# Full workflow must have a Design Doc before any source write in build/verify.
|
||||
# Catches illegal open→build / design→build jumps that skipped the design phase
|
||||
# (e.g. misclassified preset, direct `set phase`, or bare transition).
|
||||
if [ -n "$GOV_YAML" ]; then
|
||||
_wf=$(read_field "workflow" "$GOV_YAML")
|
||||
_dd=$(read_field "design_doc" "$GOV_YAML")
|
||||
if [ "$_wf" = "full" ] && { [ -z "$_dd" ] || [ "$_dd" = "null" ]; }; then
|
||||
echo "" >&2
|
||||
echo "╔══════════════════════════════════════════╗" >&2
|
||||
echo "║ COMET PHASE GUARD — WRITE BLOCKED ║" >&2
|
||||
echo "╚══════════════════════════════════════════╝" >&2
|
||||
echo "" >&2
|
||||
echo " Current phase: $PHASE (workflow: full), but design_doc is empty" >&2
|
||||
echo " Target file: $RELPATH" >&2
|
||||
echo "" >&2
|
||||
echo " ❌ Illegal phase jump detected: full workflow entered $PHASE without a Design Doc" >&2
|
||||
echo " ✅ Correct flow: create the Design Doc in design phase, then run comet-guard design --apply" >&2
|
||||
echo " 💡 Run /comet-design to fill the missing design; for repair, set design_doc with comet-state" >&2
|
||||
echo "" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
# Code writes allowed in build and verify
|
||||
echo "[COMET-HOOK] allowed: $RELPATH (phase: $PHASE)" >&2
|
||||
exit 0
|
||||
;;
|
||||
open|design|archive)
|
||||
echo "" >&2
|
||||
echo "╔══════════════════════════════════════════╗" >&2
|
||||
echo "║ COMET PHASE GUARD — WRITE BLOCKED ║" >&2
|
||||
echo "╚══════════════════════════════════════════╝" >&2
|
||||
echo "" >&2
|
||||
echo " Current phase: $PHASE" >&2
|
||||
echo " Target file: $RELPATH" >&2
|
||||
echo "" >&2
|
||||
case "$PHASE" in
|
||||
open)
|
||||
echo " ❌ open phase does not allow source code writes" >&2
|
||||
echo " ✅ Allowed: create proposal/design/tasks and run guard" >&2
|
||||
echo " 💡 After clarification and artifact creation, run guard --apply" >&2
|
||||
;;
|
||||
design)
|
||||
echo " ❌ design phase does not allow source code writes" >&2
|
||||
echo " ✅ Allowed: brainstorming, create the Design Doc, and run guard" >&2
|
||||
echo " 💡 After the Design Doc is ready, run comet-guard design --apply to enter build" >&2
|
||||
;;
|
||||
archive)
|
||||
echo " ❌ archive phase does not allow source code writes" >&2
|
||||
echo " ✅ Allowed: confirm archive intent and run the archive script" >&2
|
||||
;;
|
||||
esac
|
||||
echo "" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "[COMET-HOOK] allowed: $RELPATH (phase: $PHASE)" >&2
|
||||
exit 0
|
||||
Reference in New Issue
Block a user