init version
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
#!/bin/bash
|
||||
# Comet Archive — automates the archive phase in one command
|
||||
# Usage: comet-archive.sh <change-name> [--dry-run]
|
||||
# Exit 0 = archive complete, exit 1 = fatal error
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
COMET_BASH="${COMET_BASH:-${BASH:-bash}}"
|
||||
COMET_OPENSPEC="${COMET_OPENSPEC:-openspec}"
|
||||
|
||||
red() { echo -e "\033[31m$1\033[0m" >&2; }
|
||||
green() { echo -e "\033[32m$1\033[0m" >&2; }
|
||||
yellow() { echo -e "\033[33m$1\033[0m" >&2; }
|
||||
|
||||
DRY_RUN=0
|
||||
if [[ "${2:-}" == "--dry-run" ]]; then
|
||||
DRY_RUN=1
|
||||
fi
|
||||
|
||||
# Input validation
|
||||
validate_change_name() {
|
||||
local name="$1"
|
||||
if [ -z "$name" ]; then
|
||||
red "FATAL: Change name cannot be empty"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
|
||||
red "FATAL: Invalid change name: '$name'"
|
||||
red "Valid characters: a-z, A-Z, 0-9, -, _"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$name" =~ \.\. ]]; then
|
||||
red "FATAL: Change name cannot contain '..'"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
CHANGE="$1"
|
||||
validate_change_name "$CHANGE"
|
||||
|
||||
CHANGE_DIR="openspec/changes/$CHANGE"
|
||||
YAML="$CHANGE_DIR/.comet.yaml"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
STATE_SH="$SCRIPT_DIR/comet-state.sh"
|
||||
TODAY=$(date -u +%Y-%m-%d)
|
||||
ARCHIVE_NAME="${TODAY}-${CHANGE}"
|
||||
ARCHIVE_DIR="openspec/changes/archive/${ARCHIVE_NAME}"
|
||||
|
||||
STEPS_OK=0
|
||||
STEPS_TOTAL=0
|
||||
|
||||
step_ok() {
|
||||
green " [OK] $1"
|
||||
STEPS_OK=$((STEPS_OK + 1))
|
||||
STEPS_TOTAL=$((STEPS_TOTAL + 1))
|
||||
}
|
||||
|
||||
step_fail() {
|
||||
red " [FAIL] $1"
|
||||
STEPS_TOTAL=$((STEPS_TOTAL + 1))
|
||||
}
|
||||
|
||||
step_dry_run() {
|
||||
yellow " [DRY-RUN] $1"
|
||||
STEPS_OK=$((STEPS_OK + 1))
|
||||
STEPS_TOTAL=$((STEPS_TOTAL + 1))
|
||||
}
|
||||
|
||||
echo "=== Comet Archive: $CHANGE ===" >&2
|
||||
|
||||
# --- Step 1: Read .comet.yaml, extract paths ---
|
||||
|
||||
yaml_field() {
|
||||
local field="$1"
|
||||
if [ -f "$STATE_SH" ]; then
|
||||
"$COMET_BASH" "$STATE_SH" get "$CHANGE" "$field" 2>/dev/null
|
||||
else
|
||||
if [ -f "$YAML" ]; then
|
||||
local value
|
||||
value=$(grep "^${field}:" "$YAML" 2>/dev/null | sed "s/^${field}: *//" || true)
|
||||
value=$(strip_inline_comment "$value")
|
||||
strip_wrapping_quotes "$value"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
strip_inline_comment() {
|
||||
local value="$1"
|
||||
printf '%s\n' "$value" | awk -v squote="'" '
|
||||
{
|
||||
out = ""
|
||||
quote = ""
|
||||
for (i = 1; i <= length($0); i++) {
|
||||
c = substr($0, i, 1)
|
||||
if (quote == "") {
|
||||
if (c == "\"" || c == squote) {
|
||||
quote = c
|
||||
} else if (c == "#" && (i == 1 || substr($0, i - 1, 1) ~ /[[:space:]]/)) {
|
||||
sub(/[[:space:]]+$/, "", out)
|
||||
print out
|
||||
next
|
||||
}
|
||||
} else if (c == quote) {
|
||||
quote = ""
|
||||
}
|
||||
out = out c
|
||||
}
|
||||
print out
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
strip_wrapping_quotes() {
|
||||
local value="$1"
|
||||
case "$value" in
|
||||
\"*\") printf '%s\n' "${value:1:${#value}-2}" ;;
|
||||
\'*\') printf '%s\n' "${value:1:${#value}-2}" ;;
|
||||
*) printf '%s\n' "$value" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [ ! -f "$YAML" ]; then
|
||||
red "FATAL: .comet.yaml not found in $CHANGE_DIR/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DESIGN_DOC=$(yaml_field "design_doc")
|
||||
PLAN_PATH=$(yaml_field "plan")
|
||||
|
||||
# --- Step 2: Validate entry state ---
|
||||
|
||||
PHASE_VAL=$(yaml_field "phase")
|
||||
VERIFY_VAL=$(yaml_field "verify_result")
|
||||
ARCHIVED_VAL=$(yaml_field "archived")
|
||||
|
||||
if [ "$PHASE_VAL" != "archive" ]; then
|
||||
red "FATAL: phase is '$PHASE_VAL', expected 'archive'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$VERIFY_VAL" != "pass" ]; then
|
||||
red "FATAL: verify_result is '$VERIFY_VAL', expected 'pass'. Run comet-verify first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$ARCHIVED_VAL" = "true" ]; then
|
||||
red "FATAL: change already archived"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
step_ok "Entry state verified"
|
||||
|
||||
# --- Step 3: Check archive target ---
|
||||
|
||||
if [ -d "$ARCHIVE_DIR" ]; then
|
||||
red "FATAL: archive target already exists: $ARCHIVE_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
step_ok "Archive target available"
|
||||
|
||||
# --- Step 4: Prepare document frontmatter annotation ---
|
||||
|
||||
annotate_frontmatter() {
|
||||
local file="$1"
|
||||
local extra_fields="$2"
|
||||
|
||||
if [ ! -f "$file" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
step_dry_run "Would annotate: $file"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if head -1 "$file" | grep -q '^---'; then
|
||||
local tmp_file
|
||||
tmp_file=$(mktemp)
|
||||
chmod 600 "$tmp_file"
|
||||
awk -v archive="$ARCHIVE_NAME" -v extra="$extra_fields" '
|
||||
/^archived-with:/ { next }
|
||||
NR==1 && /^---/ { print; next }
|
||||
/^---/ && NR>1 {
|
||||
print "archived-with: " archive
|
||||
if (extra != "") print extra
|
||||
print; next
|
||||
}
|
||||
{ print }
|
||||
' "$file" > "$tmp_file"
|
||||
mv "$tmp_file" "$file"
|
||||
else
|
||||
local tmp_file
|
||||
tmp_file=$(mktemp)
|
||||
chmod 600 "$tmp_file"
|
||||
{
|
||||
echo "---"
|
||||
echo "archived-with: $ARCHIVE_NAME"
|
||||
if [ -n "$extra_fields" ]; then
|
||||
echo "$extra_fields"
|
||||
fi
|
||||
echo "status: final"
|
||||
echo "---"
|
||||
cat "$file"
|
||||
} > "$tmp_file"
|
||||
mv "$tmp_file" "$file"
|
||||
fi
|
||||
|
||||
step_ok "Annotated: $file"
|
||||
}
|
||||
|
||||
# --- Step 5: Run OpenSpec archive for delta merge and move ---
|
||||
|
||||
verify_main_specs_clean() {
|
||||
if [ ! -d "openspec/specs" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local found=0
|
||||
local matches
|
||||
for spec_file in openspec/specs/*/spec.md; do
|
||||
[ -f "$spec_file" ] || continue
|
||||
matches=$(grep -nE '^## (ADDED|MODIFIED|REMOVED|RENAMED) Requirements$' "$spec_file" 2>/dev/null || true)
|
||||
if [ -n "$matches" ]; then
|
||||
red "FATAL: delta-only section heading leaked into main spec: $spec_file"
|
||||
printf '%s\n' "$matches" >&2
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found" -ne 0 ]; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
resolve_archive_dir() {
|
||||
if [ -d "$ARCHIVE_DIR" ]; then
|
||||
return 0
|
||||
fi
|
||||
# Fallback: search for any directory matching *-$CHANGE in archive
|
||||
local found
|
||||
found=$(find "openspec/changes/archive" -maxdepth 1 -mindepth 1 -type d -name "*-$CHANGE" 2>/dev/null | head -1 || true)
|
||||
if [ -n "$found" ]; then
|
||||
ARCHIVE_DIR="$found"
|
||||
ARCHIVE_NAME=$(basename "$found")
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
step_dry_run "Would run OpenSpec archive: $CHANGE"
|
||||
else
|
||||
if ! command -v "$COMET_OPENSPEC" >/dev/null 2>&1; then
|
||||
red "FATAL: OpenSpec CLI not found: $COMET_OPENSPEC"
|
||||
red "Install OpenSpec or set COMET_OPENSPEC to the openspec executable."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$COMET_OPENSPEC" archive "$CHANGE" --yes >&2
|
||||
if ! resolve_archive_dir; then
|
||||
step_fail "OpenSpec archive output not found"
|
||||
exit 1
|
||||
else
|
||||
step_ok "OpenSpec archive completed: $ARCHIVE_DIR"
|
||||
fi
|
||||
|
||||
verify_main_specs_clean
|
||||
step_ok "Main specs verified clean"
|
||||
fi
|
||||
|
||||
# --- Step 6: Annotate design doc and plan frontmatter ---
|
||||
|
||||
if [ -n "$DESIGN_DOC" ] && [ "$DESIGN_DOC" != "null" ]; then
|
||||
annotate_frontmatter "$DESIGN_DOC" "status: final"
|
||||
fi
|
||||
|
||||
if [ -n "$PLAN_PATH" ] && [ "$PLAN_PATH" != "null" ]; then
|
||||
annotate_frontmatter "$PLAN_PATH" ""
|
||||
fi
|
||||
|
||||
# --- Step 7: Mark archived via comet-state transition ---
|
||||
|
||||
ARCHIVE_YAML="$ARCHIVE_DIR/.comet.yaml"
|
||||
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
step_dry_run "Would set archived: true in $ARCHIVE_YAML"
|
||||
else
|
||||
if [ -f "$ARCHIVE_YAML" ]; then
|
||||
"$COMET_BASH" "$STATE_SH" transition "$ARCHIVE_NAME" archived >/dev/null
|
||||
step_ok "archived: true"
|
||||
else
|
||||
step_fail "archived: true (.comet.yaml not found after move)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Step 8: Print summary ---
|
||||
|
||||
echo "" >&2
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
yellow "Dry run complete. $STEPS_OK/$STEPS_TOTAL steps would succeed."
|
||||
else
|
||||
green "Archive complete. $STEPS_OK/$STEPS_TOTAL steps succeeded."
|
||||
fi
|
||||
|
||||
if [ "$STEPS_OK" -lt "$STEPS_TOTAL" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/bin/bash
|
||||
# Comet script locator — source this file to export paths to bundled scripts.
|
||||
#
|
||||
# Usage:
|
||||
# . /path/to/comet/scripts/comet-env.sh
|
||||
#
|
||||
# This file is sourced by workflow snippets. Do not set global shell options here.
|
||||
|
||||
_comet_env_source="${BASH_SOURCE[0]:-$0}"
|
||||
_comet_script_dir="$(cd "$(dirname "$_comet_env_source")" && pwd -P)"
|
||||
_comet_env_sourced=0
|
||||
(return 0 2>/dev/null) && _comet_env_sourced=1
|
||||
|
||||
export COMET_GUARD="${COMET_GUARD:-${_comet_script_dir}/comet-guard.sh}"
|
||||
export COMET_STATE="${COMET_STATE:-${_comet_script_dir}/comet-state.sh}"
|
||||
export COMET_HANDOFF="${COMET_HANDOFF:-${_comet_script_dir}/comet-handoff.sh}"
|
||||
export COMET_ARCHIVE="${COMET_ARCHIVE:-${_comet_script_dir}/comet-archive.sh}"
|
||||
export COMET_YAML_VALIDATE="${COMET_YAML_VALIDATE:-${_comet_script_dir}/comet-yaml-validate.sh}"
|
||||
|
||||
_comet_bash_is_usable() {
|
||||
local _comet_bash_candidate="$1"
|
||||
if [ -z "$_comet_bash_candidate" ]; then
|
||||
return 1
|
||||
fi
|
||||
case "$_comet_bash_candidate" in
|
||||
*/Windows/System32/bash.exe|*/windows/system32/bash.exe|*\\Windows\\System32\\bash.exe|*\\windows\\system32\\bash.exe)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
"$_comet_bash_candidate" -lc 'printf comet-bash-ok' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
_comet_resolve_bash() {
|
||||
local _comet_bash_candidate
|
||||
|
||||
if _comet_bash_is_usable "${COMET_BASH:-}"; then
|
||||
printf '%s\n' "$COMET_BASH"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if _comet_bash_is_usable "${BASH:-}"; then
|
||||
printf '%s\n' "$BASH"
|
||||
return 0
|
||||
fi
|
||||
|
||||
_comet_bash_candidate="$(command -v sh 2>/dev/null | awk '{ sub(/\/sh(\.exe)?$/, "/bash.exe"); print }')"
|
||||
if _comet_bash_is_usable "$_comet_bash_candidate"; then
|
||||
printf '%s\n' "$_comet_bash_candidate"
|
||||
return 0
|
||||
fi
|
||||
|
||||
_comet_bash_candidate="$(command -v bash 2>/dev/null || true)"
|
||||
if _comet_bash_is_usable "$_comet_bash_candidate"; then
|
||||
printf '%s\n' "$_comet_bash_candidate"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
COMET_BASH="$(_comet_resolve_bash || true)"
|
||||
export COMET_BASH
|
||||
|
||||
_comet_env_fail() {
|
||||
echo "ERROR: Comet scripts not found. Ensure the comet skill is installed completely." >&2
|
||||
echo "Expected path pattern: */comet/scripts/comet-*.sh under project or platform skill directories" >&2
|
||||
}
|
||||
|
||||
_comet_bash_fail() {
|
||||
echo "ERROR: usable bash not found. Install Git Bash or set COMET_BASH to a working bash executable." >&2
|
||||
echo "Windows WSL launcher bash.exe is not supported for Comet scripts." >&2
|
||||
}
|
||||
|
||||
_comet_env_abort() {
|
||||
local _comet_env_was_sourced="$_comet_env_sourced"
|
||||
unset _comet_env_source _comet_script_dir _comet_script _comet_env_missing _comet_env_sourced
|
||||
unset _comet_bash_candidate
|
||||
unset -f _comet_env_fail _comet_bash_fail _comet_bash_is_usable _comet_resolve_bash
|
||||
if [ "$_comet_env_was_sourced" -eq 1 ]; then
|
||||
unset -f _comet_env_abort
|
||||
return 1
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
_comet_env_missing=0
|
||||
if [ -z "$COMET_BASH" ]; then
|
||||
_comet_bash_fail
|
||||
_comet_env_missing=1
|
||||
fi
|
||||
for _comet_script in \
|
||||
"$COMET_GUARD" \
|
||||
"$COMET_STATE" \
|
||||
"$COMET_HANDOFF" \
|
||||
"$COMET_ARCHIVE" \
|
||||
"$COMET_YAML_VALIDATE"; do
|
||||
if [ ! -f "$_comet_script" ]; then
|
||||
_comet_env_fail
|
||||
_comet_env_missing=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$_comet_env_missing" -ne 0 ]; then
|
||||
_comet_env_abort
|
||||
else
|
||||
unset _comet_env_source _comet_script_dir _comet_script _comet_env_missing _comet_env_sourced
|
||||
unset _comet_bash_candidate
|
||||
unset -f _comet_env_fail _comet_bash_fail _comet_bash_is_usable _comet_resolve_bash _comet_env_abort
|
||||
fi
|
||||
@@ -0,0 +1,778 @@
|
||||
#!/bin/bash
|
||||
# Comet Phase Guard — validates exit conditions before phase transitions
|
||||
# Usage: comet-guard.sh <change-name> <current-phase> [--apply]
|
||||
# Phases: open, design, build, verify, archive
|
||||
# Exit 0 = all checks pass, exit 1 = blocked (reasons printed to stderr)
|
||||
# shellcheck disable=SC2329 # Functions called indirectly via check() dispatch
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
COMET_BASH="${COMET_BASH:-${BASH:-bash}}"
|
||||
|
||||
red() { echo -e "\033[31m$1\033[0m" >&2; }
|
||||
green() { echo -e "\033[32m$1\033[0m" >&2; }
|
||||
warn() { echo -e "\033[33m$1\033[0m" >&2; }
|
||||
|
||||
# Input validation - prevent path traversal
|
||||
validate_change_name() {
|
||||
local name="$1"
|
||||
# Reject empty names
|
||||
if [ -z "$name" ]; then
|
||||
red "ERROR: Change name cannot be empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Only allow alphanumeric, hyphens, and underscores
|
||||
if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
|
||||
red "ERROR: Invalid change name: '$name'" >&2
|
||||
red "Valid characters: a-z, A-Z, 0-9, -, _" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Reject path traversal attempts
|
||||
if [[ "$name" =~ \.\. ]]; then
|
||||
red "ERROR: Change name cannot contain '..' (path traversal not allowed)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "${COMET_GUARD_SOURCE_ONLY:-0}" = "1" ]; then
|
||||
CHANGE="${CHANGE:-}"
|
||||
PHASE="${PHASE:-}"
|
||||
APPLY="${APPLY:-0}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd -P)"
|
||||
CHANGE_DIR="${CHANGE_DIR:-}"
|
||||
else
|
||||
validate_change_name "$1"
|
||||
|
||||
CHANGE="$1"
|
||||
PHASE="$2"
|
||||
APPLY=0
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
if [[ "${3:-}" == "--apply" ]]; then
|
||||
APPLY=1
|
||||
fi
|
||||
CHANGE_DIR="openspec/changes/$CHANGE"
|
||||
if [ "$PHASE" = "archive" ] && [ ! -d "$CHANGE_DIR" ] && [ -d "openspec/changes/archive/$CHANGE" ]; then
|
||||
CHANGE_DIR="openspec/changes/archive/$CHANGE"
|
||||
fi
|
||||
fi
|
||||
|
||||
BLOCK=0
|
||||
check() {
|
||||
local desc="$1"
|
||||
shift
|
||||
local output
|
||||
if output=$("$@" 2>&1); then
|
||||
green " [PASS] $desc"
|
||||
else
|
||||
red " [FAIL] $desc"
|
||||
if [ -n "$output" ]; then
|
||||
while IFS= read -r line; do
|
||||
red " $line"
|
||||
done <<< "$output"
|
||||
fi
|
||||
BLOCK=1
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Helper functions ---
|
||||
|
||||
tasks_all_done() {
|
||||
local tasks="$CHANGE_DIR/tasks.md"
|
||||
if [ ! -f "$tasks" ]; then
|
||||
echo "tasks.md is missing at $tasks" >&2
|
||||
echo "Next: restore or create tasks.md for this change before leaving build." >&2
|
||||
return 1
|
||||
fi
|
||||
if ! grep -q '\- \[x\]' "$tasks"; then
|
||||
echo "tasks.md has no completed tasks." >&2
|
||||
echo "Next: complete implementation tasks and mark them with '- [x]'." >&2
|
||||
return 1
|
||||
fi
|
||||
if grep -q '\- \[ \]' "$tasks"; then
|
||||
echo "Unfinished tasks:" >&2
|
||||
grep -n '\- \[ \]' "$tasks" >&2 || true
|
||||
echo "Next: complete or explicitly remove unfinished tasks, then mark tasks.md with '- [x]'." >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
tasks_has_any() {
|
||||
local tasks="$CHANGE_DIR/tasks.md"
|
||||
[ -f "$tasks" ] && grep -q '\- \[' "$tasks"
|
||||
}
|
||||
|
||||
plan_tasks_all_done() {
|
||||
local plan
|
||||
plan=$(yaml_field_value "plan" 2>/dev/null || true)
|
||||
|
||||
if [ -z "$plan" ] || [ "$plan" = "null" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ ! -f "$plan" ]; then
|
||||
echo "plan file is missing at $plan" >&2
|
||||
echo "Next: restore the Superpowers plan file or update .comet.yaml plan before leaving build." >&2
|
||||
return 1
|
||||
fi
|
||||
if grep -q '^[[:space:]]*- \[ \]' "$plan"; then
|
||||
echo "Unfinished Superpowers plan tasks:" >&2
|
||||
grep -n '^[[:space:]]*- \[ \]' "$plan" >&2 || true
|
||||
echo "Next: check off corresponding completed plan tasks, then commit the plan update." >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
yaml_field_value() {
|
||||
local field="$1"
|
||||
local yaml="$CHANGE_DIR/.comet.yaml"
|
||||
if [ -f "$yaml" ]; then
|
||||
local value
|
||||
value=$(grep "^${field}:" "$yaml" 2>/dev/null | sed "s/^${field}: *//" || true)
|
||||
value=$(strip_inline_comment "$value")
|
||||
strip_wrapping_quotes "$value"
|
||||
fi
|
||||
}
|
||||
|
||||
strip_inline_comment() {
|
||||
local value="$1"
|
||||
printf '%s\n' "$value" | awk -v squote="'" '
|
||||
{
|
||||
out = ""
|
||||
quote = ""
|
||||
for (i = 1; i <= length($0); i++) {
|
||||
c = substr($0, i, 1)
|
||||
if (quote == "") {
|
||||
if (c == "\"" || c == squote) {
|
||||
quote = c
|
||||
} else if (c == "#" && (i == 1 || substr($0, i - 1, 1) ~ /[[:space:]]/)) {
|
||||
sub(/[[:space:]]+$/, "", out)
|
||||
print out
|
||||
next
|
||||
}
|
||||
} else if (c == quote) {
|
||||
quote = ""
|
||||
}
|
||||
out = out c
|
||||
}
|
||||
print out
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
strip_wrapping_quotes() {
|
||||
local value="$1"
|
||||
case "$value" in
|
||||
\"*\")
|
||||
printf '%s\n' "${value:1:${#value}-2}"
|
||||
;;
|
||||
\'*\')
|
||||
printf '%s\n' "${value:1:${#value}-2}"
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "$value"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
project_config_value() {
|
||||
local field="$1"
|
||||
local value
|
||||
|
||||
value=$(yaml_field_value "$field" 2>/dev/null || true)
|
||||
if [ -n "$value" ] && [ "$value" != "null" ]; then
|
||||
echo "$value"
|
||||
return 0
|
||||
fi
|
||||
|
||||
for config in ".comet.yaml" "comet.yaml" ".comet.yml" "comet.yml"; do
|
||||
if [ -f "$config" ]; then
|
||||
value=$(grep "^${field}:" "$config" 2>/dev/null | sed "s/^${field}: *//" || true)
|
||||
value=$(strip_inline_comment "$value")
|
||||
value=$(strip_wrapping_quotes "$value")
|
||||
if [ -n "$value" ] && [ "$value" != "null" ]; then
|
||||
echo "$value"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
file_nonempty() {
|
||||
[ -f "$1" ] && [ -s "$1" ]
|
||||
}
|
||||
|
||||
is_windows_bash() {
|
||||
case "$(uname -s 2>/dev/null || true)" in
|
||||
MINGW*|MSYS*|CYGWIN*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
run_command_string() {
|
||||
local command="$1"
|
||||
if [ -z "$command" ]; then
|
||||
red "ERROR: build/verify command is empty" >&2
|
||||
return 1
|
||||
fi
|
||||
# Basic command injection guard: reject dangerous shell metacharacters
|
||||
# Quotes are allowed to support paths with spaces (e.g. Windows)
|
||||
if [[ "$command" =~ [\;\|\&\$\`] ]]; then
|
||||
red "ERROR: build/verify command contains shell metacharacters: $command" >&2
|
||||
red "Allowed: alphanumeric, spaces, hyphens, underscores, dots, colons, forward slashes, quotes" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "+ $command" >&2
|
||||
"$COMET_BASH" -lc "$command"
|
||||
}
|
||||
|
||||
hash_stream() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 | awk '{print $1}'
|
||||
else
|
||||
echo "sha256sum or shasum is required" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
hash_file() {
|
||||
local file="$1"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$file" | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$file" | awk '{print $1}'
|
||||
else
|
||||
echo "sha256sum or shasum is required" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
handoff_source_files() {
|
||||
printf '%s\n' "$CHANGE_DIR/proposal.md"
|
||||
printf '%s\n' "$CHANGE_DIR/design.md"
|
||||
printf '%s\n' "$CHANGE_DIR/tasks.md"
|
||||
if [ -d "$CHANGE_DIR/specs" ]; then
|
||||
find "$CHANGE_DIR/specs" -path '*/spec.md' -type f 2>/dev/null | sort
|
||||
fi
|
||||
}
|
||||
|
||||
compute_handoff_hash() {
|
||||
local hash_input
|
||||
hash_input=$(handoff_source_files | while IFS= read -r file; do
|
||||
if [ -f "$file" ]; then
|
||||
printf 'path:%s\n' "$file"
|
||||
printf 'sha256:%s\n' "$(hash_file "$file")"
|
||||
fi
|
||||
done)
|
||||
printf '%s' "$hash_input" | hash_stream
|
||||
}
|
||||
|
||||
preflight() {
|
||||
|
||||
if [ ! -d "$CHANGE_DIR" ]; then
|
||||
red "FATAL: change directory not found: $CHANGE_DIR"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$CHANGE_DIR/.comet.yaml" ]; then
|
||||
red "FATAL: .comet.yaml not found in $CHANGE_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Schema validation
|
||||
local validate_script
|
||||
validate_script="$SCRIPT_DIR/comet-yaml-validate.sh"
|
||||
if [ -f "$validate_script" ]; then
|
||||
if ! "$COMET_BASH" "$validate_script" "$CHANGE" 2>/dev/null; then
|
||||
"$COMET_BASH" "$validate_script" "$CHANGE" || true
|
||||
red "FATAL: .comet.yaml schema validation failed"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
build_passes() {
|
||||
if [ "${COMET_SKIP_BUILD:-0}" = "1" ]; then
|
||||
return 0
|
||||
fi
|
||||
local configured_build
|
||||
configured_build=$(project_config_value "build_command" 2>/dev/null || true)
|
||||
if [ -n "$configured_build" ]; then
|
||||
run_command_string "$configured_build"
|
||||
return $?
|
||||
fi
|
||||
if [ -f "package.json" ] && grep -q '"build"' "package.json"; then
|
||||
npm run build
|
||||
return $?
|
||||
fi
|
||||
if [ -f "pom.xml" ]; then
|
||||
if [ -x "./mvnw" ]; then
|
||||
./mvnw compile -q
|
||||
elif is_windows_bash && command -v mvn.cmd >/dev/null 2>&1; then
|
||||
mvn.cmd compile -q
|
||||
else
|
||||
mvn compile -q
|
||||
fi
|
||||
return $?
|
||||
fi
|
||||
if [ -f "Cargo.toml" ]; then
|
||||
cargo build
|
||||
return $?
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
verification_command_passes() {
|
||||
if [ "${COMET_SKIP_BUILD:-0}" = "1" ]; then
|
||||
return 0
|
||||
fi
|
||||
local configured_verify
|
||||
configured_verify=$(project_config_value "verify_command" 2>/dev/null || true)
|
||||
if [ -n "$configured_verify" ]; then
|
||||
run_command_string "$configured_verify"
|
||||
return $?
|
||||
fi
|
||||
build_passes
|
||||
}
|
||||
|
||||
isolation_selected() {
|
||||
local isolation
|
||||
isolation=$(yaml_field_value "isolation" 2>/dev/null || true)
|
||||
case "$isolation" in
|
||||
branch|worktree) return 0 ;;
|
||||
*)
|
||||
echo "isolation must be branch or worktree, got '${isolation:-null}'" >&2
|
||||
echo "Next: ask the user to choose branch or worktree, create the chosen isolation, then run:" >&2
|
||||
echo " \"\$COMET_BASH\" \"\$COMET_STATE\" set $CHANGE isolation <branch|worktree>" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_mode_selected() {
|
||||
local build_mode
|
||||
build_mode=$(yaml_field_value "build_mode" 2>/dev/null || true)
|
||||
case "$build_mode" in
|
||||
subagent-driven-development|executing-plans|direct) return 0 ;;
|
||||
*)
|
||||
echo "build_mode must be selected before leaving build, got '${build_mode:-null}'" >&2
|
||||
echo "Next: ask the user to choose an execution mode, then run:" >&2
|
||||
echo " \"\$COMET_BASH\" \"\$COMET_STATE\" set $CHANGE build_mode <subagent-driven-development|executing-plans>" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_mode_allowed_for_workflow() {
|
||||
local workflow build_mode direct_override
|
||||
workflow=$(yaml_field_value "workflow" 2>/dev/null || true)
|
||||
build_mode=$(yaml_field_value "build_mode" 2>/dev/null || true)
|
||||
direct_override=$(yaml_field_value "direct_override" 2>/dev/null || true)
|
||||
|
||||
if [ "$build_mode" != "direct" ]; then
|
||||
return 0
|
||||
fi
|
||||
case "$workflow" in
|
||||
hotfix|tweak) return 0 ;;
|
||||
*)
|
||||
if [ "$direct_override" = "true" ]; then
|
||||
return 0
|
||||
fi
|
||||
echo "build_mode=direct is only allowed for hotfix/tweak unless direct_override: true is recorded" >&2
|
||||
echo "Next: choose executing-plans or subagent-driven-development, or stop and ask the user for an explicit direct override." >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
subagent_dispatch_confirmed() {
|
||||
local build_mode subagent_dispatch
|
||||
build_mode=$(yaml_field_value "build_mode" 2>/dev/null || true)
|
||||
subagent_dispatch=$(yaml_field_value "subagent_dispatch" 2>/dev/null || true)
|
||||
|
||||
if [ "$build_mode" != "subagent-driven-development" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$subagent_dispatch" = "confirmed" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "subagent_dispatch must be confirmed before using build_mode=subagent-driven-development" >&2
|
||||
echo "Next: confirm the current platform has a real background subagent/Task/multi-agent dispatcher, then run:" >&2
|
||||
echo " \"\$COMET_BASH\" \"\$COMET_STATE\" set $CHANGE subagent_dispatch confirmed" >&2
|
||||
echo "Or ask the user to switch to executing-plans and run:" >&2
|
||||
echo " \"\$COMET_BASH\" \"\$COMET_STATE\" set $CHANGE build_mode executing-plans" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
tdd_mode_selected() {
|
||||
local workflow tdd_mode
|
||||
workflow=$(yaml_field_value "workflow" 2>/dev/null || true)
|
||||
tdd_mode=$(yaml_field_value "tdd_mode" 2>/dev/null || true)
|
||||
|
||||
case "$workflow" in
|
||||
hotfix|tweak) return 0 ;;
|
||||
esac
|
||||
|
||||
case "$tdd_mode" in
|
||||
tdd|direct) return 0 ;;
|
||||
*)
|
||||
echo "tdd_mode must be tdd or direct for full workflow, got '${tdd_mode:-null}'" >&2
|
||||
echo "Next: ask the user to choose TDD enforcement level, then run:" >&2
|
||||
echo " \"\$COMET_BASH\" \"\$COMET_STATE\" set $CHANGE tdd_mode <tdd|direct>" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
review_mode_selected() {
|
||||
local workflow review_mode
|
||||
workflow=$(yaml_field_value "workflow" 2>/dev/null || true)
|
||||
review_mode=$(yaml_field_value "review_mode" 2>/dev/null || true)
|
||||
|
||||
case "$workflow" in
|
||||
hotfix|tweak) return 0 ;;
|
||||
esac
|
||||
|
||||
case "$review_mode" in
|
||||
off|standard|thorough) return 0 ;;
|
||||
*)
|
||||
echo "review_mode must be off, standard, or thorough before leaving build, got '${review_mode:-null}'" >&2
|
||||
echo "Next: ask the user to choose code review mode, then run:" >&2
|
||||
echo " \"\$COMET_BASH\" \"\$COMET_STATE\" set $CHANGE review_mode <off|standard|thorough>" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
verify_result_is_pass() {
|
||||
local result
|
||||
result=$(yaml_field_value "verify_result" 2>/dev/null || true)
|
||||
[ "$result" = "pass" ]
|
||||
}
|
||||
|
||||
verification_report_exists() {
|
||||
local report
|
||||
report=$(yaml_field_value "verification_report" 2>/dev/null || true)
|
||||
[ -n "$report" ] && [ "$report" != "null" ] && [ -f "$report" ]
|
||||
}
|
||||
|
||||
branch_status_handled() {
|
||||
local status
|
||||
status=$(yaml_field_value "branch_status" 2>/dev/null || true)
|
||||
[ "$status" = "handled" ]
|
||||
}
|
||||
|
||||
design_handoff_context_valid() {
|
||||
local context recorded_hash actual_hash markdown
|
||||
context=$(yaml_field_value "handoff_context" 2>/dev/null || true)
|
||||
recorded_hash=$(yaml_field_value "handoff_hash" 2>/dev/null || true)
|
||||
|
||||
if [ -z "$context" ] || [ "$context" = "null" ]; then
|
||||
echo "handoff_context is missing from .comet.yaml" >&2
|
||||
echo "Next: run \"\$COMET_BASH\" \"\$COMET_HANDOFF\" $CHANGE design --write before invoking Superpowers." >&2
|
||||
return 1
|
||||
fi
|
||||
if [ ! -s "$context" ]; then
|
||||
echo "handoff_context does not point to a non-empty file: $context" >&2
|
||||
echo "Next: regenerate the design handoff with comet-handoff.sh." >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! "$recorded_hash" =~ ^[a-f0-9]{64}$ ]]; then
|
||||
echo "handoff_hash is missing or invalid: ${recorded_hash:-null}" >&2
|
||||
echo "Next: regenerate the design handoff with comet-handoff.sh." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
actual_hash=$(compute_handoff_hash)
|
||||
if [ "$actual_hash" != "$recorded_hash" ]; then
|
||||
echo "OpenSpec artifacts changed after handoff was generated." >&2
|
||||
echo "Expected handoff_hash: $recorded_hash" >&2
|
||||
echo "Actual handoff_hash: $actual_hash" >&2
|
||||
echo "Next: rerun comet-handoff.sh so Superpowers receives the current OpenSpec context." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
markdown="${context%.json}.md"
|
||||
if [ ! -s "$markdown" ]; then
|
||||
echo "design handoff markdown is missing or empty: $markdown" >&2
|
||||
echo "Next: regenerate the design handoff with comet-handoff.sh." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
design_handoff_markdown_traceable() {
|
||||
local context markdown missing=0
|
||||
context=$(yaml_field_value "handoff_context" 2>/dev/null || true)
|
||||
if [ -z "$context" ] || [ "$context" = "null" ]; then
|
||||
echo "handoff_context is missing from .comet.yaml" >&2
|
||||
return 1
|
||||
fi
|
||||
markdown="${context%.json}.md"
|
||||
if [ ! -s "$markdown" ]; then
|
||||
echo "design handoff markdown is missing or empty: $markdown" >&2
|
||||
return 1
|
||||
fi
|
||||
grep -q '^Generated-by: comet-handoff\.sh$' "$markdown" || {
|
||||
echo "handoff markdown is missing Generated-by marker" >&2
|
||||
missing=1
|
||||
}
|
||||
grep -Eq '^- Mode: (compact|full|beta)$' "$markdown" || {
|
||||
echo "handoff markdown is missing Mode marker" >&2
|
||||
missing=1
|
||||
}
|
||||
handoff_source_files | while IFS= read -r file; do
|
||||
[ -f "$file" ] || continue
|
||||
if ! grep -q "^- Source: $file$" "$markdown"; then
|
||||
echo "handoff markdown is missing source reference: $file" >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! grep -q "^- SHA256: $(hash_file "$file")$" "$markdown"; then
|
||||
echo "handoff markdown is missing current sha256 for: $file" >&2
|
||||
exit 2
|
||||
fi
|
||||
done || missing=1
|
||||
|
||||
[ "$missing" -eq 0 ]
|
||||
}
|
||||
|
||||
context_compression_mode() {
|
||||
local mode
|
||||
mode=$(yaml_field_value "context_compression" 2>/dev/null || true)
|
||||
printf '%s\n' "${mode:-off}"
|
||||
}
|
||||
|
||||
beta_spec_json_structurally_valid() {
|
||||
local context missing=0
|
||||
if [ "$(context_compression_mode)" != "beta" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
context=$(yaml_field_value "handoff_context" 2>/dev/null || true)
|
||||
if [ -z "$context" ] || [ "$context" = "null" ]; then
|
||||
echo "handoff_context is missing from .comet.yaml" >&2
|
||||
return 1
|
||||
fi
|
||||
if [ ! -s "$context" ]; then
|
||||
echo "spec-context.json is missing or empty: $context" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validate required JSON fields
|
||||
grep -q '"change"' "$context" || { echo "spec-context.json missing 'change' field" >&2; return 1; }
|
||||
grep -q '"phase"' "$context" || { echo "spec-context.json missing 'phase' field" >&2; return 1; }
|
||||
grep -q '"mode": "beta"' "$context" || { echo "spec-context.json mode is not beta" >&2; return 1; }
|
||||
grep -q '"files"' "$context" || { echo "spec-context.json missing 'files' field" >&2; return 1; }
|
||||
grep -q '"context_hash"' "$context" || { echo "spec-context.json missing 'context_hash' field" >&2; return 1; }
|
||||
|
||||
# Verify all source files are referenced in the JSON
|
||||
handoff_source_files | while IFS= read -r file; do
|
||||
[ -f "$file" ] || continue
|
||||
if ! grep -qF "$file" "$context"; then
|
||||
echo "spec-context.json missing source file reference: $file" >&2
|
||||
exit 2
|
||||
fi
|
||||
done || missing=1
|
||||
|
||||
[ "$missing" -eq 0 ]
|
||||
}
|
||||
|
||||
design_doc_frontmatter_has() {
|
||||
local design_doc="$1"
|
||||
local field="$2"
|
||||
local expected="$3"
|
||||
awk '
|
||||
{
|
||||
line = $0
|
||||
sub(/^\357\273\277/, "", line)
|
||||
}
|
||||
!in_fm && line == "---" { in_fm = 1; next }
|
||||
in_fm && line == "---" { exit }
|
||||
in_fm { print line }
|
||||
' "$design_doc" | grep -Eq "^${field}: ['\"]?${expected}['\"]?[[:space:]]*$"
|
||||
}
|
||||
|
||||
design_doc_links_current_change() {
|
||||
local design_doc
|
||||
design_doc=$(yaml_field_value "design_doc" 2>/dev/null || true)
|
||||
if [ -z "$design_doc" ] || [ "$design_doc" = "null" ] || [ ! -s "$design_doc" ]; then
|
||||
echo "design_doc must point to an existing Superpowers Design Doc before leaving design." >&2
|
||||
return 1
|
||||
fi
|
||||
design_doc_frontmatter_has "$design_doc" "comet_change" "$CHANGE"
|
||||
}
|
||||
|
||||
design_doc_declares_technical_role() {
|
||||
local design_doc
|
||||
design_doc=$(yaml_field_value "design_doc" 2>/dev/null || true)
|
||||
[ -n "$design_doc" ] && [ "$design_doc" != "null" ] && [ -s "$design_doc" ] &&
|
||||
design_doc_frontmatter_has "$design_doc" "role" "technical-design"
|
||||
}
|
||||
|
||||
design_doc_declares_canonical_spec() {
|
||||
local design_doc
|
||||
design_doc=$(yaml_field_value "design_doc" 2>/dev/null || true)
|
||||
[ -n "$design_doc" ] && [ "$design_doc" != "null" ] && [ -s "$design_doc" ] &&
|
||||
design_doc_frontmatter_has "$design_doc" "canonical_spec" "openspec"
|
||||
}
|
||||
|
||||
archived_is_true() {
|
||||
local val
|
||||
val=$(yaml_field_value "archived" 2>/dev/null || true)
|
||||
[ "$val" = "true" ]
|
||||
}
|
||||
|
||||
# --- Phase-specific checks ---
|
||||
|
||||
guard_open() {
|
||||
echo "=== Guard: open → next ===" >&2
|
||||
|
||||
local workflow
|
||||
workflow=$(yaml_field_value "workflow" 2>/dev/null || true)
|
||||
|
||||
check "proposal.md exists and non-empty" file_nonempty "$CHANGE_DIR/proposal.md"
|
||||
if [ "$workflow" = "full" ]; then
|
||||
check "design.md exists and non-empty" file_nonempty "$CHANGE_DIR/design.md"
|
||||
fi
|
||||
check "tasks.md exists and non-empty" file_nonempty "$CHANGE_DIR/tasks.md"
|
||||
check "tasks.md has at least one task" tasks_has_any
|
||||
}
|
||||
|
||||
guard_design() {
|
||||
echo "=== Guard: design → build ===" >&2
|
||||
|
||||
local design_doc workflow
|
||||
design_doc=$(yaml_field_value "design_doc" 2>/dev/null || true)
|
||||
workflow=$(yaml_field_value "workflow" 2>/dev/null || true)
|
||||
|
||||
check "proposal.md exists" file_nonempty "$CHANGE_DIR/proposal.md"
|
||||
check "design.md exists" file_nonempty "$CHANGE_DIR/design.md"
|
||||
check "tasks.md exists" file_nonempty "$CHANGE_DIR/tasks.md"
|
||||
check "design handoff context exists" design_handoff_context_valid
|
||||
check "design handoff markdown is traceable" design_handoff_markdown_traceable
|
||||
if [ "$(context_compression_mode)" = "beta" ]; then
|
||||
check "beta spec-context.json is structurally valid" beta_spec_json_structurally_valid
|
||||
fi
|
||||
|
||||
if [ "$workflow" = "full" ]; then
|
||||
# Full workflow: design_doc is REQUIRED
|
||||
check "design_doc is recorded for full workflow" design_doc_recorded
|
||||
fi
|
||||
|
||||
if [ -n "$design_doc" ] && [ "$design_doc" != "null" ]; then
|
||||
check "Design Doc ($design_doc) exists" file_nonempty "$design_doc"
|
||||
check "Design Doc frontmatter links current change" design_doc_links_current_change
|
||||
check "Design Doc declares technical design role" design_doc_declares_technical_role
|
||||
check "Design Doc declares OpenSpec as canonical spec" design_doc_declares_canonical_spec
|
||||
elif [ "$workflow" != "full" ]; then
|
||||
warn " [WARN] No design_doc recorded in .comet.yaml (optional for hotfix/tweak)"
|
||||
fi
|
||||
}
|
||||
|
||||
design_doc_recorded() {
|
||||
local design_doc
|
||||
design_doc=$(yaml_field_value "design_doc" 2>/dev/null || true)
|
||||
if [ -n "$design_doc" ] && [ "$design_doc" != "null" ] && [ -f "$design_doc" ]; then
|
||||
return 0
|
||||
fi
|
||||
echo "design_doc must point to an existing Superpowers Design Doc for full workflow before leaving design." >&2
|
||||
echo "Next: create the Design Doc and run: \"\$COMET_BASH\" \"\$COMET_STATE\" set $CHANGE design_doc <path>" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
guard_build() {
|
||||
echo "=== Guard: build → verify ===" >&2
|
||||
|
||||
check "isolation selected" isolation_selected
|
||||
check "build_mode selected" build_mode_selected
|
||||
check "build_mode allowed for workflow" build_mode_allowed_for_workflow
|
||||
check "subagent dispatch confirmed" subagent_dispatch_confirmed
|
||||
check "tdd_mode selected" tdd_mode_selected
|
||||
check "review_mode selected" review_mode_selected
|
||||
check "tasks.md all tasks checked" tasks_all_done
|
||||
check "Superpowers plan all tasks checked" plan_tasks_all_done
|
||||
check "proposal.md exists" file_nonempty "$CHANGE_DIR/proposal.md"
|
||||
check "Build passes" build_passes
|
||||
}
|
||||
|
||||
guard_verify() {
|
||||
echo "=== Guard: verify → archive ===" >&2
|
||||
|
||||
check "tasks.md all tasks checked" tasks_all_done
|
||||
check "Build passes" verification_command_passes
|
||||
check "verification_report exists" verification_report_exists
|
||||
check "branch_status=handled" branch_status_handled
|
||||
}
|
||||
|
||||
guard_archive() {
|
||||
echo "=== Guard: archive completeness ===" >&2
|
||||
|
||||
check "archived is true" archived_is_true
|
||||
check "proposal.md exists" file_nonempty "$CHANGE_DIR/proposal.md"
|
||||
check "design.md exists" file_nonempty "$CHANGE_DIR/design.md"
|
||||
check "tasks.md all tasks checked" tasks_all_done
|
||||
}
|
||||
|
||||
apply_state_update() {
|
||||
local state_sh="$SCRIPT_DIR/comet-state.sh"
|
||||
local p="$1"
|
||||
|
||||
if [ -f "$state_sh" ]; then
|
||||
case "$p" in
|
||||
open) "$COMET_BASH" "$state_sh" transition "$CHANGE" open-complete ;;
|
||||
design) "$COMET_BASH" "$state_sh" transition "$CHANGE" design-complete ;;
|
||||
build) "$COMET_BASH" "$state_sh" transition "$CHANGE" build-complete ;;
|
||||
verify) "$COMET_BASH" "$state_sh" transition "$CHANGE" verify-pass ;;
|
||||
esac
|
||||
else
|
||||
red "FATAL: comet-state.sh not found; cannot apply state transition"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
|
||||
if [ "${COMET_GUARD_SOURCE_ONLY:-0}" = "1" ]; then
|
||||
return 0 2>/dev/null
|
||||
# shellcheck disable=SC2317 # unreachable if sourced; fallback for direct execution
|
||||
red "ERROR: COMET_GUARD_SOURCE_ONLY=1 is only for sourcing, not direct execution" >&2
|
||||
# shellcheck disable=SC2317
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$PHASE" in
|
||||
open) preflight ; guard_open ;;
|
||||
design) preflight ; guard_design ;;
|
||||
build) preflight ; guard_build ;;
|
||||
verify) preflight ; guard_verify ;;
|
||||
archive) preflight ; guard_archive ;;
|
||||
*)
|
||||
red "Unknown phase: $PHASE"
|
||||
echo "Valid phases: open, design, build, verify, archive" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$BLOCK" -eq 1 ]; then
|
||||
echo "" >&2
|
||||
red "BLOCKED — fix failing checks before proceeding to next phase"
|
||||
exit 1
|
||||
else
|
||||
echo "" >&2
|
||||
green "ALL CHECKS PASSED — ready for next phase"
|
||||
if [ "$APPLY" -eq 1 ]; then
|
||||
apply_state_update "$PHASE"
|
||||
case "$PHASE" in
|
||||
open)
|
||||
new_phase=$(yaml_field_value "phase")
|
||||
green " [APPLY] .comet.yaml updated: phase=$new_phase"
|
||||
;;
|
||||
design) green " [APPLY] .comet.yaml updated: phase=build" ;;
|
||||
build) green " [APPLY] .comet.yaml updated: phase=verify, verify_result=pending" ;;
|
||||
verify) green " [APPLY] .comet.yaml updated: phase=archive, verify_result=pass" ;;
|
||||
esac
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
@@ -0,0 +1,390 @@
|
||||
#!/bin/bash
|
||||
# Comet Handoff — creates machine-owned context packages between phases
|
||||
# Usage: comet-handoff.sh <change-name> design --write [--full]
|
||||
# comet-handoff.sh <change-name> --hash-only
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
COMET_BASH="${COMET_BASH:-${BASH:-bash}}"
|
||||
|
||||
red() { echo -e "\033[31m$1\033[0m" >&2; }
|
||||
green() { echo -e "\033[32m$1\033[0m" >&2; }
|
||||
warn() { echo -e "\033[33m$1\033[0m" >&2; }
|
||||
|
||||
validate_change_name() {
|
||||
local name="$1"
|
||||
if [ -z "$name" ]; then
|
||||
red "ERROR: Change name cannot be empty"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
|
||||
red "ERROR: Invalid change name: '$name'"
|
||||
red "Valid characters: a-z, A-Z, 0-9, -, _"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$name" =~ \.\. ]]; then
|
||||
red "ERROR: Change name cannot contain '..' (path traversal not allowed)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
strip_wrapping_quotes() {
|
||||
local value="$1"
|
||||
case "$value" in
|
||||
\"*\") printf '%s\n' "${value:1:${#value}-2}" ;;
|
||||
\'*\') printf '%s\n' "${value:1:${#value}-2}" ;;
|
||||
*) printf '%s\n' "$value" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
strip_inline_comment() {
|
||||
local value="$1"
|
||||
printf '%s\n' "$value" | awk -v squote="'" '
|
||||
{
|
||||
out = ""
|
||||
quote = ""
|
||||
for (i = 1; i <= length($0); i++) {
|
||||
c = substr($0, i, 1)
|
||||
if (quote == "") {
|
||||
if (c == "\"" || c == squote) {
|
||||
quote = c
|
||||
} else if (c == "#" && (i == 1 || substr($0, i - 1, 1) ~ /[[:space:]]/)) {
|
||||
sub(/[[:space:]]+$/, "", out)
|
||||
print out
|
||||
next
|
||||
}
|
||||
} else if (c == quote) {
|
||||
quote = ""
|
||||
}
|
||||
out = out c
|
||||
}
|
||||
print out
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
yaml_field_value() {
|
||||
local field="$1"
|
||||
local yaml="$CHANGE_DIR/.comet.yaml"
|
||||
local value
|
||||
value=$(grep "^${field}:" "$yaml" 2>/dev/null | sed "s/^${field}: *//" || true)
|
||||
value=$(strip_inline_comment "$value")
|
||||
strip_wrapping_quotes "$value"
|
||||
}
|
||||
|
||||
hash_stream() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 | awk '{print $1}'
|
||||
else
|
||||
red "ERROR: sha256sum or shasum is required"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
hash_file() {
|
||||
local file="$1"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$file" | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$file" | awk '{print $1}'
|
||||
else
|
||||
red "ERROR: sha256sum or shasum is required"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
source_files() {
|
||||
printf '%s\n' "$CHANGE_DIR/proposal.md"
|
||||
printf '%s\n' "$CHANGE_DIR/design.md"
|
||||
printf '%s\n' "$CHANGE_DIR/tasks.md"
|
||||
if [ -d "$CHANGE_DIR/specs" ]; then
|
||||
find "$CHANGE_DIR/specs" -path '*/spec.md' -type f 2>/dev/null | sort
|
||||
fi
|
||||
}
|
||||
|
||||
compute_context_hash() {
|
||||
local hash_input
|
||||
hash_input=$(source_files | while IFS= read -r file; do
|
||||
if [ -f "$file" ]; then
|
||||
printf 'path:%s\n' "$file"
|
||||
printf 'sha256:%s\n' "$(hash_file "$file")"
|
||||
fi
|
||||
done)
|
||||
printf '%s' "$hash_input" | hash_stream
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
file_line_count() {
|
||||
local file="$1"
|
||||
wc -l < "$file" | tr -d ' '
|
||||
}
|
||||
|
||||
write_file_excerpt() {
|
||||
local file="$1"
|
||||
local max_lines="$2"
|
||||
local total_lines
|
||||
total_lines=$(file_line_count "$file")
|
||||
|
||||
echo "## $file"
|
||||
echo ""
|
||||
echo "- Source: $file"
|
||||
echo "- Lines: 1-$total_lines"
|
||||
echo "- SHA256: $(hash_file "$file")"
|
||||
echo ""
|
||||
|
||||
if [ "$HANDOFF_MODE" = "full" ] || [ "$total_lines" -le "$max_lines" ]; then
|
||||
echo '```md'
|
||||
cat "$file"
|
||||
echo '```'
|
||||
else
|
||||
echo "[TRUNCATED]"
|
||||
echo ""
|
||||
echo '```md'
|
||||
sed -n "1,${max_lines}p" "$file"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Full source: $file"
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
write_markdown_context() {
|
||||
local output="$1"
|
||||
{
|
||||
echo "# Comet Design Handoff"
|
||||
echo ""
|
||||
echo "- Change: $CHANGE"
|
||||
echo "- Phase: design"
|
||||
echo "- Mode: $HANDOFF_MODE"
|
||||
echo "- Context hash: $CONTEXT_HASH"
|
||||
echo ""
|
||||
echo "Generated-by: comet-handoff.sh"
|
||||
echo ""
|
||||
echo "OpenSpec remains the canonical capability spec. This handoff is a deterministic, source-traceable context pack, not an agent-authored summary."
|
||||
echo ""
|
||||
source_files | while IFS= read -r file; do
|
||||
[ -f "$file" ] || continue
|
||||
write_file_excerpt "$file" 80
|
||||
done
|
||||
} > "$output"
|
||||
}
|
||||
|
||||
write_json_context() {
|
||||
local output="$1"
|
||||
{
|
||||
echo "{"
|
||||
echo " \"change\": \"$(json_escape "$CHANGE")\","
|
||||
echo " \"phase\": \"design\","
|
||||
echo " \"mode\": \"$HANDOFF_MODE\","
|
||||
echo " \"canonical_spec\": \"openspec\","
|
||||
echo " \"generated_by\": \"comet-handoff.sh\","
|
||||
echo " \"context_hash\": \"$CONTEXT_HASH\","
|
||||
echo " \"files\": ["
|
||||
local first=1
|
||||
while IFS= read -r file; do
|
||||
[ -f "$file" ] || continue
|
||||
if [ "$first" -eq 0 ]; then
|
||||
echo ","
|
||||
fi
|
||||
first=0
|
||||
printf ' { "path": "%s", "sha256": "%s" }' "$(json_escape "$file")" "$(hash_file "$file")"
|
||||
done < <(source_files)
|
||||
echo ""
|
||||
echo " ]"
|
||||
echo "}"
|
||||
} > "$output"
|
||||
}
|
||||
|
||||
write_spec_projection_for_file() {
|
||||
local file="$1"
|
||||
echo "## $file"
|
||||
echo ""
|
||||
echo "- Source: $file"
|
||||
echo "- Lines: 1-$(file_line_count "$file")"
|
||||
echo "- SHA256: $(hash_file "$file")"
|
||||
echo ""
|
||||
echo '```md'
|
||||
cat "$file"
|
||||
echo '```'
|
||||
echo ""
|
||||
}
|
||||
|
||||
write_spec_markdown_context() {
|
||||
local output="$1"
|
||||
{
|
||||
echo "# Comet Spec Context"
|
||||
echo ""
|
||||
echo "- Change: $CHANGE"
|
||||
echo "- Phase: design"
|
||||
echo "- Mode: beta"
|
||||
echo "- Context hash: $CONTEXT_HASH"
|
||||
echo ""
|
||||
echo "Generated-by: comet-handoff.sh"
|
||||
echo ""
|
||||
echo "OpenSpec remains the canonical capability spec. This beta context pack verbatim-projects spec files and references supporting artifacts by hash, not an agent-authored summary."
|
||||
echo ""
|
||||
echo "## Source References"
|
||||
echo ""
|
||||
source_files | while IFS= read -r file; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "- Source: $file"
|
||||
echo "- SHA256: $(hash_file "$file")"
|
||||
done
|
||||
echo ""
|
||||
echo "## Acceptance Projection"
|
||||
echo ""
|
||||
if [ -d "$CHANGE_DIR/specs" ]; then
|
||||
find "$CHANGE_DIR/specs" -path '*/spec.md' -type f 2>/dev/null | sort | while IFS= read -r file; do
|
||||
write_spec_projection_for_file "$file"
|
||||
done
|
||||
else
|
||||
echo "No delta spec files found."
|
||||
echo ""
|
||||
fi
|
||||
echo "Full source files remain canonical. If a required heading or scenario is missing here, regenerate the handoff or read the source spec directly. Supporting files (proposal, design, tasks) are referenced by hash only."
|
||||
} > "$output"
|
||||
}
|
||||
|
||||
write_spec_json_context() {
|
||||
local output="$1"
|
||||
{
|
||||
echo "{"
|
||||
echo " \"change\": \"$(json_escape "$CHANGE")\","
|
||||
echo " \"phase\": \"design\","
|
||||
echo " \"mode\": \"beta\","
|
||||
echo " \"canonical_spec\": \"openspec\","
|
||||
echo " \"generated_by\": \"comet-handoff.sh\","
|
||||
echo " \"context_hash\": \"$CONTEXT_HASH\","
|
||||
echo " \"files\": ["
|
||||
local first_file=1
|
||||
while IFS= read -r file; do
|
||||
[ -f "$file" ] || continue
|
||||
local role="supporting"
|
||||
case "$file" in
|
||||
*/specs/*/spec.md) role="spec" ;;
|
||||
esac
|
||||
if [ "$first_file" -eq 0 ]; then
|
||||
echo ","
|
||||
fi
|
||||
first_file=0
|
||||
printf ' { "path": "%s", "sha256": "%s", "role": "%s" }' "$(json_escape "$file")" "$(hash_file "$file")" "$role"
|
||||
done < <(source_files)
|
||||
echo ""
|
||||
echo " ]"
|
||||
echo "}"
|
||||
} > "$output"
|
||||
}
|
||||
|
||||
CHANGE="${1:-}"
|
||||
PHASE="${2:-}"
|
||||
MODE="${3:-}"
|
||||
FULL_FLAG="${4:-}"
|
||||
|
||||
validate_change_name "$CHANGE"
|
||||
|
||||
# --hash-only: compute and output context hash without generating handoff files
|
||||
if [ "${PHASE:-}" = "--hash-only" ]; then
|
||||
CHANGE_DIR="openspec/changes/$CHANGE"
|
||||
if [ ! -d "$CHANGE_DIR" ]; then
|
||||
red "ERROR: change directory not found: $CHANGE_DIR"
|
||||
exit 1
|
||||
fi
|
||||
for required in proposal.md design.md tasks.md; do
|
||||
if [ ! -s "$CHANGE_DIR/$required" ]; then
|
||||
red "ERROR: required file missing or empty: $CHANGE_DIR/$required"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
CONTEXT_HASH="$(compute_context_hash)"
|
||||
printf '%s
|
||||
' "$CONTEXT_HASH"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$PHASE" != "design" ] || [ "$MODE" != "--write" ]; then
|
||||
red "Usage: comet-handoff.sh <change-name> design --write [--full]"
|
||||
exit 1
|
||||
fi
|
||||
case "$FULL_FLAG" in
|
||||
"") HANDOFF_MODE="compact" ;;
|
||||
--full) HANDOFF_MODE="full" ;;
|
||||
*)
|
||||
red "Usage: comet-handoff.sh <change-name> design --write [--full]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
CHANGE_DIR="openspec/changes/$CHANGE"
|
||||
YAML="$CHANGE_DIR/.comet.yaml"
|
||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")" 2>/dev/null || dirname "$0")"
|
||||
STATE_SH="$SCRIPT_DIR/comet-state.sh"
|
||||
|
||||
if [ ! -d "$CHANGE_DIR" ]; then
|
||||
red "ERROR: change directory not found: $CHANGE_DIR"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$YAML" ]; then
|
||||
red "ERROR: .comet.yaml not found at $YAML"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(yaml_field_value phase)" != "design" ]; then
|
||||
red "ERROR: design handoff requires phase: design"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for required in proposal.md design.md tasks.md; do
|
||||
if [ ! -s "$CHANGE_DIR/$required" ]; then
|
||||
red "ERROR: required OpenSpec artifact missing or empty: $CHANGE_DIR/$required"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
HANDOFF_DIR="$CHANGE_DIR/.comet/handoff"
|
||||
CONTEXT_COMPRESSION="$(yaml_field_value context_compression 2>/dev/null || true)"
|
||||
CONTEXT_COMPRESSION="${CONTEXT_COMPRESSION:-off}"
|
||||
case "$CONTEXT_COMPRESSION" in
|
||||
off)
|
||||
CONTEXT_JSON="$HANDOFF_DIR/design-context.json"
|
||||
CONTEXT_MD="$HANDOFF_DIR/design-context.md"
|
||||
;;
|
||||
beta)
|
||||
if [ "$HANDOFF_MODE" = "full" ]; then
|
||||
warn "[HANDOFF] --full is ignored in beta mode; spec files are projected verbatim"
|
||||
fi
|
||||
HANDOFF_MODE="beta"
|
||||
CONTEXT_JSON="$HANDOFF_DIR/spec-context.json"
|
||||
CONTEXT_MD="$HANDOFF_DIR/spec-context.md"
|
||||
;;
|
||||
*)
|
||||
red "ERROR: invalid context_compression: $CONTEXT_COMPRESSION"
|
||||
red "Valid values: off, beta"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
mkdir -p "$HANDOFF_DIR"
|
||||
|
||||
CONTEXT_HASH="$(compute_context_hash)"
|
||||
if [ "$CONTEXT_COMPRESSION" = "beta" ]; then
|
||||
write_spec_markdown_context "$CONTEXT_MD"
|
||||
write_spec_json_context "$CONTEXT_JSON"
|
||||
else
|
||||
write_markdown_context "$CONTEXT_MD"
|
||||
write_json_context "$CONTEXT_JSON"
|
||||
fi
|
||||
|
||||
if [ -x "$STATE_SH" ] || [ -f "$STATE_SH" ]; then
|
||||
"$COMET_BASH" "$STATE_SH" set "$CHANGE" handoff_context "$CONTEXT_JSON" >/dev/null
|
||||
"$COMET_BASH" "$STATE_SH" set "$CHANGE" handoff_hash "$CONTEXT_HASH" >/dev/null
|
||||
else
|
||||
red "ERROR: comet-state.sh not found; cannot record handoff fields"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
green "[HANDOFF] wrote $CONTEXT_JSON"
|
||||
green "[HANDOFF] wrote $CONTEXT_MD"
|
||||
green "[HANDOFF] handoff_hash=$CONTEXT_HASH"
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,225 @@
|
||||
#!/bin/bash
|
||||
# Comet YAML Schema Validator — validates .comet.yaml structure
|
||||
# Usage: comet-yaml-validate.sh <change-name>
|
||||
# Exit 0 = valid, exit 1 = errors found (printed to stderr)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
red() { echo -e "\033[31m$1\033[0m" >&2; }
|
||||
green() { echo -e "\033[32m$1\033[0m" >&2; }
|
||||
warn() { echo -e "\033[33m$1\033[0m" >&2; }
|
||||
|
||||
# Input validation - prevent path traversal
|
||||
validate_change_name() {
|
||||
local name="$1"
|
||||
# Reject empty names
|
||||
if [ -z "$name" ]; then
|
||||
red "ERROR: Change name cannot be empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Only allow alphanumeric, hyphens, and underscores
|
||||
if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
|
||||
red "ERROR: Invalid change name: '$name'" >&2
|
||||
red "Valid characters: a-z, A-Z, 0-9, -, _" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Reject path traversal attempts
|
||||
if [[ "$name" =~ \.\. ]]; then
|
||||
red "ERROR: Change name cannot contain '..' (path traversal not allowed)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_change_name "$1"
|
||||
|
||||
CHANGE="$1"
|
||||
CHANGE_DIR="openspec/changes/$CHANGE"
|
||||
if [ ! -d "$CHANGE_DIR" ] && [ -d "openspec/changes/archive/$CHANGE" ]; then
|
||||
CHANGE_DIR="openspec/changes/archive/$CHANGE"
|
||||
fi
|
||||
YAML="$CHANGE_DIR/.comet.yaml"
|
||||
|
||||
ERRORS=0
|
||||
WARNINGS=0
|
||||
|
||||
# Helper: get value of a top-level field (handles null, empty, quoted)
|
||||
field_value() {
|
||||
local value
|
||||
value=$(grep "^${1}:" "$YAML" 2>/dev/null | sed "s/^${1}: *//" || true)
|
||||
value=$(strip_inline_comment "$value")
|
||||
strip_wrapping_quotes "$value"
|
||||
}
|
||||
|
||||
strip_inline_comment() {
|
||||
local value="$1"
|
||||
printf '%s\n' "$value" | awk -v squote="'" '
|
||||
{
|
||||
out = ""
|
||||
quote = ""
|
||||
for (i = 1; i <= length($0); i++) {
|
||||
c = substr($0, i, 1)
|
||||
if (quote == "") {
|
||||
if (c == "\"" || c == squote) {
|
||||
quote = c
|
||||
} else if (c == "#" && (i == 1 || substr($0, i - 1, 1) ~ /[[:space:]]/)) {
|
||||
sub(/[[:space:]]+$/, "", out)
|
||||
print out
|
||||
next
|
||||
}
|
||||
} else if (c == quote) {
|
||||
quote = ""
|
||||
}
|
||||
out = out c
|
||||
}
|
||||
print out
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
strip_wrapping_quotes() {
|
||||
local value="$1"
|
||||
case "$value" in
|
||||
\"*\")
|
||||
printf '%s\n' "${value:1:${#value}-2}"
|
||||
;;
|
||||
\'*\')
|
||||
printf '%s\n' "${value:1:${#value}-2}"
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "$value"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
fail() { red " FAIL: $1"; ERRORS=$((ERRORS + 1)); }
|
||||
warn_msg() { warn " WARN: $1"; WARNINGS=$((WARNINGS + 1)); }
|
||||
|
||||
echo "[VALIDATE] $YAML" >&2
|
||||
|
||||
# --- Required fields ---
|
||||
REQUIRED_FIELDS="workflow phase design_doc plan build_mode isolation verify_mode verify_result verified_at archived"
|
||||
for field in $REQUIRED_FIELDS; do
|
||||
if ! grep -q "^${field}:" "$YAML" 2>/dev/null; then
|
||||
fail "missing required field '$field'"
|
||||
fi
|
||||
done
|
||||
|
||||
# --- Enum validation ---
|
||||
validate_enum() {
|
||||
local field="$1" value="$2"
|
||||
shift 2
|
||||
local valid_values="$*"
|
||||
|
||||
# null or empty is always acceptable
|
||||
if [ -z "$value" ] || [ "$value" = "null" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
for v in $valid_values; do
|
||||
if [ "$value" = "$v" ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
fail "$field='$value' is not valid. Expected: $valid_values"
|
||||
}
|
||||
|
||||
validate_required_enum() {
|
||||
local field="$1" value="$2"
|
||||
shift 2
|
||||
local valid_values="$*"
|
||||
|
||||
if [ -z "$value" ] || [ "$value" = "null" ]; then
|
||||
fail "$field='${value:-}' is not valid. Expected: $valid_values"
|
||||
return 0
|
||||
fi
|
||||
|
||||
validate_enum "$field" "$value" "$@"
|
||||
}
|
||||
|
||||
workflow=$(field_value "workflow")
|
||||
phase=$(field_value "phase")
|
||||
context_compression=$(field_value "context_compression")
|
||||
build_mode=$(field_value "build_mode")
|
||||
build_pause=$(field_value "build_pause")
|
||||
subagent_dispatch=$(field_value "subagent_dispatch")
|
||||
tdd_mode=$(field_value "tdd_mode")
|
||||
review_mode=$(field_value "review_mode")
|
||||
isolation=$(field_value "isolation")
|
||||
verify_mode=$(field_value "verify_mode")
|
||||
auto_transition=$(field_value "auto_transition")
|
||||
verify_result=$(field_value "verify_result")
|
||||
branch_status=$(field_value "branch_status")
|
||||
archived=$(field_value "archived")
|
||||
direct_override=$(field_value "direct_override")
|
||||
design_doc=$(field_value "design_doc")
|
||||
plan=$(field_value "plan")
|
||||
handoff_context=$(field_value "handoff_context")
|
||||
handoff_hash=$(field_value "handoff_hash")
|
||||
|
||||
validate_enum "workflow" "$workflow" "full hotfix tweak"
|
||||
validate_enum "phase" "$phase" "open design build verify archive"
|
||||
validate_enum "context_compression" "$context_compression" "off beta"
|
||||
validate_enum "build_mode" "$build_mode" "subagent-driven-development executing-plans direct"
|
||||
validate_enum "build_pause" "$build_pause" "null plan-ready"
|
||||
validate_enum "subagent_dispatch" "$subagent_dispatch" "null confirmed"
|
||||
validate_enum "tdd_mode" "$tdd_mode" "tdd direct null"
|
||||
validate_enum "review_mode" "$review_mode" "off standard thorough"
|
||||
validate_enum "isolation" "$isolation" "branch worktree"
|
||||
validate_enum "verify_mode" "$verify_mode" "light full"
|
||||
if grep -q "^auto_transition:" "$YAML" 2>/dev/null; then
|
||||
validate_required_enum "auto_transition" "$auto_transition" "true false"
|
||||
fi
|
||||
validate_enum "verify_result" "$verify_result" "pending pass fail"
|
||||
validate_enum "branch_status" "$branch_status" "pending handled"
|
||||
validate_enum "archived" "$archived" "true false"
|
||||
validate_enum "direct_override" "$direct_override" "true false"
|
||||
|
||||
# --- Path validation ---
|
||||
|
||||
if [ -n "$design_doc" ] && [ "$design_doc" != "null" ]; then
|
||||
if [ ! -f "$design_doc" ]; then
|
||||
fail "design_doc='$design_doc' does not exist on disk"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$plan" ] && [ "$plan" != "null" ]; then
|
||||
if [ ! -f "$plan" ]; then
|
||||
fail "plan='$plan' does not exist on disk"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$handoff_context" ] && [ "$handoff_context" != "null" ]; then
|
||||
if [ ! -f "$handoff_context" ]; then
|
||||
fail "handoff_context='$handoff_context' does not exist on disk"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$handoff_hash" ] && [ "$handoff_hash" != "null" ]; then
|
||||
if [[ ! "$handoff_hash" =~ ^[a-f0-9]{64}$ ]]; then
|
||||
fail "handoff_hash='$handoff_hash' is not a sha256 hex digest"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Unknown keys check ---
|
||||
KNOWN_KEYS="workflow phase context_compression design_doc plan build_mode build_pause subagent_dispatch tdd_mode review_mode isolation verify_mode auto_transition verify_result verification_report branch_status verified_at created_at archived direct_override build_command verify_command handoff_context handoff_hash base_ref"
|
||||
while IFS=: read -r key _; do
|
||||
key="${key// /}"
|
||||
[ -z "$key" ] && continue
|
||||
found=0
|
||||
for known in $KNOWN_KEYS; do
|
||||
[ "$key" = "$known" ] && found=1 && break
|
||||
done
|
||||
if [ "$found" -eq 0 ]; then
|
||||
warn_msg "unknown field '$key' found"
|
||||
fi
|
||||
done < "$YAML"
|
||||
|
||||
# --- Summary ---
|
||||
echo "" >&2
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
red "$ERRORS error(s), $WARNINGS warning(s) — validation FAILED"
|
||||
exit 1
|
||||
else
|
||||
green "0 errors, $WARNINGS warning(s) — validation PASSED"
|
||||
exit 0
|
||||
fi
|
||||
Reference in New Issue
Block a user