update first - 84
This commit is contained in:
+208
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN H6 AgentOps Harness
|
||||
# Usage:
|
||||
# agent-metrics.sh <input-file> <output-file> [-- <command> ...]
|
||||
#
|
||||
# If command is omitted, the script performs a pass-through copy. If command is
|
||||
# provided, it runs with CASAN_INPUT and CASAN_OUTPUT environment variables.
|
||||
|
||||
INPUT_FILE="${1:-}"
|
||||
OUTPUT_FILE="${2:-}"
|
||||
shift 2 || true
|
||||
if [[ "${1:-}" == "--" ]]; then
|
||||
shift
|
||||
fi
|
||||
|
||||
if [[ -z "$INPUT_FILE" || -z "$OUTPUT_FILE" ]]; then
|
||||
echo "Usage: agent-metrics.sh <input-file> <output-file> [-- <command> ...]" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
LOG_DIR="$PROJECT_ROOT/.specify/logs"
|
||||
TRACE_DIR="$LOG_DIR/trace"
|
||||
METRICS_DIR="$LOG_DIR/cost"
|
||||
ALERT_LOG="$PROJECT_ROOT/.specify/agentops/alerts.log"
|
||||
METRICS_LOG="$METRICS_DIR/metrics.jsonl"
|
||||
mkdir -p "$TRACE_DIR" "$METRICS_DIR" "$(dirname "$OUTPUT_FILE")" "$(dirname "$ALERT_LOG")"
|
||||
|
||||
# shellcheck source=tool-audit-lib.sh
|
||||
source "$SCRIPT_DIR/tool-audit-lib.sh"
|
||||
|
||||
if [[ ! -f "$INPUT_FILE" ]]; then
|
||||
echo "AGENTOPS_FAILED: input file not found: $INPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
timestamp() {
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ"
|
||||
}
|
||||
|
||||
epoch_ms() {
|
||||
python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || printf '%s000\n' "$(date +%s)"
|
||||
}
|
||||
|
||||
new_trace_id() {
|
||||
if command -v uuidgen >/dev/null 2>&1; then
|
||||
uuidgen | tr '[:upper:]' '[:lower:]'
|
||||
else
|
||||
printf 'trace-%s-%s\n' "$(date +%s)" "$$"
|
||||
fi
|
||||
}
|
||||
|
||||
hash_text() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum | awk '{print $1}'
|
||||
else
|
||||
shasum -a 256 | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
word_count() {
|
||||
wc -w < "$1" | tr -d ' '
|
||||
}
|
||||
|
||||
TRACE_ID="$(new_trace_id)"
|
||||
START_TS="$(timestamp)"
|
||||
START_MS="$(epoch_ms)"
|
||||
STATUS="success"
|
||||
ERROR_MSG=""
|
||||
EXIT_CODE=0
|
||||
RETRY_COUNT="${CASAN_RETRY_COUNT:-0}"
|
||||
AGENT_NAME="${CASAN_AGENT_NAME:-unknown-agent}"
|
||||
STEP_NAME="${CASAN_STEP_NAME:-unknown-step}"
|
||||
|
||||
INPUT_TOKENS="$(word_count "$INPUT_FILE")"
|
||||
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
set +e
|
||||
CASAN_INPUT="$INPUT_FILE" CASAN_OUTPUT="$OUTPUT_FILE" "$@"
|
||||
EXIT_CODE=$?
|
||||
set -e
|
||||
if [[ "$EXIT_CODE" -ne 0 ]]; then
|
||||
STATUS="failed"
|
||||
ERROR_MSG="command exited with code $EXIT_CODE"
|
||||
fi
|
||||
|
||||
TOOL_AUDIT_RECORD="$(python3 - "$START_TS" "$TRACE_ID" "$AGENT_NAME" "$STEP_NAME" "$*" "$EXIT_CODE" "$STATUS" <<'PY'
|
||||
import json, sys
|
||||
ts, trace, agent, step, cmd, code, status = sys.argv[1:]
|
||||
print(json.dumps({
|
||||
"timestamp": ts, "trace_id": trace, "agent": agent, "step": step,
|
||||
"tool": "Bash", "command": cmd, "exit_code": int(code), "status": status,
|
||||
}))
|
||||
PY
|
||||
)"
|
||||
append_tool_audit "$TOOL_AUDIT_RECORD" "$PROJECT_ROOT"
|
||||
else
|
||||
cp "$INPUT_FILE" "$OUTPUT_FILE"
|
||||
fi
|
||||
|
||||
END_MS="$(epoch_ms)"
|
||||
LATENCY_MS=$((END_MS - START_MS))
|
||||
|
||||
if [[ ! -f "$OUTPUT_FILE" ]]; then
|
||||
STATUS="failed"
|
||||
ERROR_MSG="${ERROR_MSG:-output file not produced}"
|
||||
: > "$OUTPUT_FILE"
|
||||
fi
|
||||
|
||||
OUTPUT_TOKENS="$(word_count "$OUTPUT_FILE")"
|
||||
TOTAL_TOKENS=$((INPUT_TOKENS + OUTPUT_TOKENS))
|
||||
COST_PER_1K="${CASAN_COST_PER_1K:-0.002}"
|
||||
COST_ESTIMATE="$(python3 - "$TOTAL_TOKENS" "$COST_PER_1K" <<'PY'
|
||||
import sys
|
||||
tokens = int(sys.argv[1])
|
||||
rate = float(sys.argv[2])
|
||||
print(f"{tokens * rate / 1000:.8f}")
|
||||
PY
|
||||
)"
|
||||
COST_SOURCE="word_count_estimate"
|
||||
|
||||
# Prefer real provider usage when telemetry has been imported; the word-count
|
||||
# figure above is an explicit fallback, not presented as a real billed cost.
|
||||
PROVIDER_LOG="$PROJECT_ROOT/.specify/logs/level5/provider-usage.jsonl"
|
||||
if [[ -f "$PROVIDER_LOG" ]] && command -v python3 >/dev/null 2>&1; then
|
||||
# Use real provider telemetry ONLY when a record genuinely matches this step.
|
||||
# Do NOT fall back to an arbitrary record (that would reuse one sample's cost
|
||||
# across every step and misrepresent it as real per-step billing).
|
||||
PROV="$(python3 "$SCRIPT_DIR/provider-cost-lookup.py" "$PROVIDER_LOG" "$STEP_NAME")"
|
||||
if [[ -n "$PROV" ]]; then
|
||||
TOTAL_TOKENS="${PROV%% *}"
|
||||
COST_ESTIMATE="${PROV##* }"
|
||||
COST_SOURCE="provider_telemetry"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Real hallucination-signal detection (populates hallucination-tracking.yaml's metric).
|
||||
HALLU_YAML="$PROJECT_ROOT/.specify/agentops/hallucination-tracking.yaml"
|
||||
HALLUCINATION_SIGNALS=0
|
||||
HALLUCINATION_MATCHED="[]"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
HSCAN="$(python3 "$SCRIPT_DIR/hallucination-scan.py" "$HALLU_YAML" "$OUTPUT_FILE" 2>/dev/null || printf '0\n[]')"
|
||||
HALLUCINATION_SIGNALS="$(printf '%s' "$HSCAN" | head -1)"
|
||||
HALLUCINATION_MATCHED="$(printf '%s' "$HSCAN" | tail -1)"
|
||||
fi
|
||||
|
||||
INPUT_HASH="$(cat "$INPUT_FILE" | hash_text)"
|
||||
OUTPUT_HASH="$(cat "$OUTPUT_FILE" | hash_text)"
|
||||
|
||||
ALERTS=()
|
||||
if [[ "$LATENCY_MS" -gt "${CASAN_LATENCY_ALERT_MS:-5000}" ]]; then
|
||||
ALERTS+=("high-latency")
|
||||
fi
|
||||
if [[ "$RETRY_COUNT" -gt "${CASAN_RETRY_ALERT_THRESHOLD:-2}" ]]; then
|
||||
ALERTS+=("high-retry")
|
||||
fi
|
||||
if [[ "$STATUS" == "failed" ]]; then
|
||||
ALERTS+=("execution-failed")
|
||||
fi
|
||||
if [[ "$TOTAL_TOKENS" -gt "${CASAN_TOKEN_ALERT_THRESHOLD:-5000}" ]]; then
|
||||
ALERTS+=("token-overuse")
|
||||
fi
|
||||
if [[ "$HALLUCINATION_SIGNALS" -ge "${CASAN_HALLUCINATION_WARN:-3}" ]]; then
|
||||
ALERTS+=("hallucination-suspected")
|
||||
fi
|
||||
|
||||
ALERTS_JSON="$(printf '%s\n' "${ALERTS[@]:-}" | python3 -c 'import json,sys; print(json.dumps([x for x in sys.stdin.read().splitlines() if x]))')"
|
||||
TRACE_FILE="$TRACE_DIR/agentops-$TRACE_ID.json"
|
||||
cat > "$TRACE_FILE" <<EOF
|
||||
{
|
||||
"trace_id": "$TRACE_ID",
|
||||
"timestamp": "$START_TS",
|
||||
"harness": "H6-agentops",
|
||||
"agent": "$AGENT_NAME",
|
||||
"step": "$STEP_NAME",
|
||||
"status": "$STATUS",
|
||||
"exit_code": $EXIT_CODE,
|
||||
"latency_ms": $LATENCY_MS,
|
||||
"retry_count": $RETRY_COUNT,
|
||||
"input_tokens": $INPUT_TOKENS,
|
||||
"output_tokens": $OUTPUT_TOKENS,
|
||||
"total_tokens": $TOTAL_TOKENS,
|
||||
"cost_estimate": $COST_ESTIMATE,
|
||||
"cost_source": "$COST_SOURCE",
|
||||
"hallucination_signals": $HALLUCINATION_SIGNALS,
|
||||
"hallucination_matched": $HALLUCINATION_MATCHED,
|
||||
"alerts": $ALERTS_JSON,
|
||||
"input_hash": "$INPUT_HASH",
|
||||
"output_hash": "$OUTPUT_HASH",
|
||||
"error": "$ERROR_MSG"
|
||||
}
|
||||
EOF
|
||||
|
||||
printf '{"timestamp":"%s","trace_id":"%s","harness":"H6-agentops","agent":"%s","step":"%s","status":"%s","exit_code":%s,"latency_ms":%s,"retry_count":%s,"input_tokens":%s,"output_tokens":%s,"total_tokens":%s,"cost_estimate":%s,"cost_source":"%s","hallucination_signals":%s,"alerts":%s,"input_hash":"%s","output_hash":"%s"}\n' \
|
||||
"$START_TS" "$TRACE_ID" "$AGENT_NAME" "$STEP_NAME" "$STATUS" "$EXIT_CODE" "$LATENCY_MS" "$RETRY_COUNT" "$INPUT_TOKENS" "$OUTPUT_TOKENS" "$TOTAL_TOKENS" "$COST_ESTIMATE" "$COST_SOURCE" "$HALLUCINATION_SIGNALS" "$ALERTS_JSON" "$INPUT_HASH" "$OUTPUT_HASH" >> "$METRICS_LOG"
|
||||
|
||||
for alert in "${ALERTS[@]:-}"; do
|
||||
if [[ -n "$alert" ]]; then
|
||||
printf '{"timestamp":"%s","trace_id":"%s","severity":"WARN","resource":{"service.name":"%s","service.version":"1.0.0"},"body":{"message":"Alert triggered: %s","alert.type":"%s","step.name":"%s"},"attributes":{"latency_ms":%s,"status":"%s"}}\n' \
|
||||
"$START_TS" "$TRACE_ID" "$AGENT_NAME" "$alert" "$alert" "$STEP_NAME" "$LATENCY_MS" "$STATUS" >> "$ALERT_LOG"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "AGENTOPS_RECORDED trace_id=$TRACE_ID status=$STATUS latency_ms=$LATENCY_MS tokens=$TOTAL_TOKENS cost=$COST_ESTIMATE output=$OUTPUT_FILE"
|
||||
exit "$EXIT_CODE"
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN Level 5 business KPI feedback report.
|
||||
# Usage:
|
||||
# business-kpi-report.sh <input-json> <output-json>
|
||||
|
||||
INPUT_JSON="${1:-}"
|
||||
OUTPUT_JSON="${2:-}"
|
||||
if [[ -z "$INPUT_JSON" || -z "$OUTPUT_JSON" ]]; then
|
||||
echo "Usage: business-kpi-report.sh <input-json> <output-json>" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$OUTPUT_JSON")"
|
||||
|
||||
python3 - "$INPUT_JSON" "$OUTPUT_JSON" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
input_path, output_path = sys.argv[1], sys.argv[2]
|
||||
data = json.load(open(input_path, encoding="utf-8"))
|
||||
results = []
|
||||
for item in data["kpis"]:
|
||||
baseline = float(item["baseline"])
|
||||
current = float(item["current"])
|
||||
target = float(item["target"])
|
||||
direction = item.get("direction", "lower_is_better")
|
||||
if direction == "lower_is_better":
|
||||
improvement = (baseline - current) / baseline if baseline else 0
|
||||
target_met = current <= target
|
||||
else:
|
||||
improvement = (current - baseline) / baseline if baseline else 0
|
||||
target_met = current >= target
|
||||
results.append({
|
||||
"id": item["id"],
|
||||
"baseline": baseline,
|
||||
"current": current,
|
||||
"target": target,
|
||||
"improvement_ratio": round(improvement, 4),
|
||||
"target_met": target_met,
|
||||
})
|
||||
|
||||
report = {
|
||||
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"harness": "L5-business-feedback",
|
||||
"status": "pass" if all(r["target_met"] for r in results) else "warn",
|
||||
"kpis": results,
|
||||
}
|
||||
json.dump(report, open(output_path, "w", encoding="utf-8"), indent=2)
|
||||
print(f"KPI_REPORT status={report['status']} output={output_path}")
|
||||
PY
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN Level 5 unified harness wrapper.
|
||||
# Usage:
|
||||
# casan-harness.sh <input-file> <output-file> [action-name] [-- <command> ...]
|
||||
#
|
||||
# Flow:
|
||||
# H4 input security -> H5 governance -> H6 metrics around execution/cache -> H4 output filter
|
||||
|
||||
INPUT_FILE="${1:-}"
|
||||
FINAL_OUTPUT="${2:-}"
|
||||
ACTION_NAME="${3:-agent_step}"
|
||||
shift 3 || true
|
||||
if [[ "${1:-}" == "--" ]]; then
|
||||
shift
|
||||
fi
|
||||
|
||||
if [[ -z "$INPUT_FILE" || -z "$FINAL_OUTPUT" ]]; then
|
||||
echo "Usage: casan-harness.sh <input-file> <output-file> [action-name] [-- <command> ...]" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
TMP_DIR="$PROJECT_ROOT/.specify/logs/tmp"
|
||||
CACHE_DIR="$PROJECT_ROOT/.specify/logs/idempotency"
|
||||
mkdir -p "$TMP_DIR" "$CACHE_DIR" "$(dirname "$FINAL_OUTPUT")"
|
||||
|
||||
hash_text() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum | awk '{print $1}'
|
||||
else
|
||||
shasum -a 256 | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
CMD_STR="${*:-no_cmd}"
|
||||
INPUT_HASH="$(cat "$INPUT_FILE" | hash_text)"
|
||||
CMD_HASH="$(printf '%s' "$CMD_STR" | hash_text)"
|
||||
IDEMPOTENCY_KEY="$(printf '%s|%s|%s' "$INPUT_HASH" "$CMD_HASH" "$ACTION_NAME" | hash_text)"
|
||||
CACHE_META="$CACHE_DIR/$IDEMPOTENCY_KEY.json"
|
||||
CACHE_OUT="$CACHE_DIR/$IDEMPOTENCY_KEY.output"
|
||||
|
||||
TRACE_SUFFIX="$(date +%s)-$$"
|
||||
SAFE_INPUT="$TMP_DIR/security-input-$TRACE_SUFFIX.txt"
|
||||
APPROVED_INPUT="$TMP_DIR/governance-approved-$TRACE_SUFFIX.txt"
|
||||
RAW_OUTPUT="$TMP_DIR/raw-output-$TRACE_SUFFIX.txt"
|
||||
|
||||
"$SCRIPT_DIR/security-check.sh" "$INPUT_FILE" "$SAFE_INPUT" input
|
||||
"$SCRIPT_DIR/governance-check.sh" "$SAFE_INPUT" "$APPROVED_INPUT" "$ACTION_NAME"
|
||||
|
||||
# H2 tool registry gate is in the line of fire for side-effecting actions:
|
||||
# it enforces idempotency key, per-agent permission, and rollback strategy
|
||||
# before the command is allowed to execute. The wrapper already derived a
|
||||
# content-addressed idempotency key above.
|
||||
case "$ACTION_NAME" in
|
||||
write_code|migration|deploy|db_write|external_api|write_file)
|
||||
CASAN_IDEMPOTENCY_KEY="$IDEMPOTENCY_KEY" "$SCRIPT_DIR/tool-registry-gate.sh" "$ACTION_NAME"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -f "$CACHE_META" && -f "$CACHE_OUT" ]]; then
|
||||
"$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- bash -c 'cp "$1" "$CASAN_OUTPUT"' _ "$CACHE_OUT"
|
||||
CACHE_STATUS="cached"
|
||||
elif [[ "$#" -gt 0 ]]; then
|
||||
"$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- "$@"
|
||||
CACHE_STATUS="stored"
|
||||
else
|
||||
"$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT"
|
||||
CACHE_STATUS="stored"
|
||||
fi
|
||||
|
||||
"$SCRIPT_DIR/security-check.sh" "$RAW_OUTPUT" "$FINAL_OUTPUT" output
|
||||
|
||||
if [[ "$CACHE_STATUS" == "stored" ]]; then
|
||||
cat <<EOF > "$CACHE_META"
|
||||
{
|
||||
"idempotency_key": "$IDEMPOTENCY_KEY",
|
||||
"timestamp": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
|
||||
"action": "$ACTION_NAME",
|
||||
"command": "$(printf '%s' "$CMD_STR" | sed 's/"/\\"/g')",
|
||||
"output_hash": "$(cat "$FINAL_OUTPUT" | hash_text)"
|
||||
}
|
||||
EOF
|
||||
cp "$FINAL_OUTPUT" "$CACHE_OUT"
|
||||
fi
|
||||
|
||||
echo "CASAN_HARNESS_COMPLETE cache=$CACHE_STATUS key=$IDEMPOTENCY_KEY output=$FINAL_OUTPUT"
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Consolidated prerequisite checking script
|
||||
#
|
||||
# This script provides unified prerequisite checking for Spec-Driven Development workflow.
|
||||
# It replaces the functionality previously spread across multiple scripts.
|
||||
#
|
||||
# Usage: ./check-prerequisites.sh [OPTIONS]
|
||||
#
|
||||
# OPTIONS:
|
||||
# --json Output in JSON format
|
||||
# --require-tasks Require tasks.md to exist (for implementation phase)
|
||||
# --include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||
# --paths-only Only output path variables (no validation)
|
||||
# --help, -h Show help message
|
||||
#
|
||||
# OUTPUTS:
|
||||
# JSON mode: {"FEATURE_DIR":"...", "AVAILABLE_DOCS":["..."]}
|
||||
# Text mode: FEATURE_DIR:... \n AVAILABLE_DOCS: \n ✓/✗ file.md
|
||||
# Paths only: REPO_ROOT: ... \n BRANCH: ... \n FEATURE_DIR: ... etc.
|
||||
|
||||
set -e
|
||||
|
||||
# Parse command line arguments
|
||||
JSON_MODE=false
|
||||
REQUIRE_TASKS=false
|
||||
INCLUDE_TASKS=false
|
||||
PATHS_ONLY=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--json)
|
||||
JSON_MODE=true
|
||||
;;
|
||||
--require-tasks)
|
||||
REQUIRE_TASKS=true
|
||||
;;
|
||||
--include-tasks)
|
||||
INCLUDE_TASKS=true
|
||||
;;
|
||||
--paths-only)
|
||||
PATHS_ONLY=true
|
||||
;;
|
||||
--help|-h)
|
||||
cat << 'EOF'
|
||||
Usage: check-prerequisites.sh [OPTIONS]
|
||||
|
||||
Consolidated prerequisite checking for Spec-Driven Development workflow.
|
||||
|
||||
OPTIONS:
|
||||
--json Output in JSON format
|
||||
--require-tasks Require tasks.md to exist (for implementation phase)
|
||||
--include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||
--paths-only Only output path variables (no prerequisite validation)
|
||||
--help, -h Show this help message
|
||||
|
||||
EXAMPLES:
|
||||
# Check task prerequisites (plan.md required)
|
||||
./check-prerequisites.sh --json
|
||||
|
||||
# Check implementation prerequisites (plan.md + tasks.md required)
|
||||
./check-prerequisites.sh --json --require-tasks --include-tasks
|
||||
|
||||
# Get feature paths only (no validation)
|
||||
./check-prerequisites.sh --paths-only
|
||||
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Source common functions
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
# Get feature paths and validate branch
|
||||
eval $(get_feature_paths)
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
|
||||
# If paths-only mode, output paths and exit (support JSON + paths-only combined)
|
||||
if $PATHS_ONLY; then
|
||||
if $JSON_MODE; then
|
||||
# Minimal JSON paths payload (no validation performed)
|
||||
printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \
|
||||
"$REPO_ROOT" "$CURRENT_BRANCH" "$FEATURE_DIR" "$FEATURE_SPEC" "$IMPL_PLAN" "$TASKS"
|
||||
else
|
||||
echo "REPO_ROOT: $REPO_ROOT"
|
||||
echo "BRANCH: $CURRENT_BRANCH"
|
||||
echo "FEATURE_DIR: $FEATURE_DIR"
|
||||
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
||||
echo "IMPL_PLAN: $IMPL_PLAN"
|
||||
echo "TASKS: $TASKS"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Validate required directories and files
|
||||
if [[ ! -d "$FEATURE_DIR" ]]; then
|
||||
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2
|
||||
echo "Run /speckit.specify first to create the feature structure." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run /speckit.plan first to create the implementation plan." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for tasks.md if required
|
||||
if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then
|
||||
echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run /speckit.tasks first to create the task list." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build list of available documents
|
||||
docs=()
|
||||
|
||||
# Always check these optional docs
|
||||
[[ -f "$RESEARCH" ]] && docs+=("research.md")
|
||||
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")
|
||||
|
||||
# Check contracts directory (only if it exists and has files)
|
||||
if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then
|
||||
docs+=("contracts/")
|
||||
fi
|
||||
|
||||
[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md")
|
||||
|
||||
# Include tasks.md if requested and it exists
|
||||
if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then
|
||||
docs+=("tasks.md")
|
||||
fi
|
||||
|
||||
# Output results
|
||||
if $JSON_MODE; then
|
||||
# Build JSON array of documents
|
||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||
json_docs="[]"
|
||||
else
|
||||
json_docs=$(printf '"%s",' "${docs[@]}")
|
||||
json_docs="[${json_docs%,}]"
|
||||
fi
|
||||
|
||||
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$FEATURE_DIR" "$json_docs"
|
||||
else
|
||||
# Text output
|
||||
echo "FEATURE_DIR:$FEATURE_DIR"
|
||||
echo "AVAILABLE_DOCS:"
|
||||
|
||||
# Show status of each potential document
|
||||
check_file "$RESEARCH" "research.md"
|
||||
check_file "$DATA_MODEL" "data-model.md"
|
||||
check_dir "$CONTRACTS_DIR" "contracts/"
|
||||
check_file "$QUICKSTART" "quickstart.md"
|
||||
|
||||
if $INCLUDE_TASKS; then
|
||||
check_file "$TASKS" "tasks.md"
|
||||
fi
|
||||
fi
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env bash
|
||||
# Common functions and variables for all scripts
|
||||
|
||||
# Get repository root, with fallback for non-git repositories
|
||||
get_repo_root() {
|
||||
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
git rev-parse --show-toplevel
|
||||
else
|
||||
# Fall back to script location for non-git repos
|
||||
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
(cd "$script_dir/../../.." && pwd)
|
||||
fi
|
||||
}
|
||||
|
||||
# Get current branch, with fallback for non-git repositories
|
||||
get_current_branch() {
|
||||
# First check if SPECIFY_FEATURE environment variable is set
|
||||
if [[ -n "${SPECIFY_FEATURE:-}" ]]; then
|
||||
echo "$SPECIFY_FEATURE"
|
||||
return
|
||||
fi
|
||||
|
||||
# Then check git if available
|
||||
if git rev-parse --abbrev-ref HEAD >/dev/null 2>&1; then
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
return
|
||||
fi
|
||||
|
||||
# For non-git repos, try to find the latest feature directory
|
||||
local repo_root=$(get_repo_root)
|
||||
local specs_dir="$repo_root/specs"
|
||||
|
||||
if [[ -d "$specs_dir" ]]; then
|
||||
local latest_feature=""
|
||||
local highest=0
|
||||
|
||||
for dir in "$specs_dir"/*; do
|
||||
if [[ -d "$dir" ]]; then
|
||||
local dirname=$(basename "$dir")
|
||||
if [[ "$dirname" =~ ^([0-9]{3})- ]]; then
|
||||
local number=${BASH_REMATCH[1]}
|
||||
number=$((10#$number))
|
||||
if [[ "$number" -gt "$highest" ]]; then
|
||||
highest=$number
|
||||
latest_feature=$dirname
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "$latest_feature" ]]; then
|
||||
echo "$latest_feature"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "main" # Final fallback
|
||||
}
|
||||
|
||||
# Check if we have git available
|
||||
has_git() {
|
||||
git rev-parse --show-toplevel >/dev/null 2>&1
|
||||
}
|
||||
|
||||
check_feature_branch() {
|
||||
local branch="$1"
|
||||
local has_git_repo="$2"
|
||||
|
||||
# For non-git repos, we can't enforce branch naming but still provide output
|
||||
if [[ "$has_git_repo" != "true" ]]; then
|
||||
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! "$branch" =~ ^[0-9]{3}- ]]; then
|
||||
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
|
||||
echo "Feature branches should be named like: 001-feature-name" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
get_feature_dir() { echo "$1/specs/$2"; }
|
||||
|
||||
# Find feature directory by numeric prefix instead of exact branch match
|
||||
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
|
||||
find_feature_dir_by_prefix() {
|
||||
local repo_root="$1"
|
||||
local branch_name="$2"
|
||||
local specs_dir="$repo_root/specs"
|
||||
|
||||
# Extract numeric prefix from branch (e.g., "004" from "004-whatever")
|
||||
if [[ ! "$branch_name" =~ ^([0-9]{3})- ]]; then
|
||||
# If branch doesn't have numeric prefix, fall back to exact match
|
||||
echo "$specs_dir/$branch_name"
|
||||
return
|
||||
fi
|
||||
|
||||
local prefix="${BASH_REMATCH[1]}"
|
||||
|
||||
# Search for directories in specs/ that start with this prefix
|
||||
local matches=()
|
||||
if [[ -d "$specs_dir" ]]; then
|
||||
for dir in "$specs_dir"/"$prefix"-*; do
|
||||
if [[ -d "$dir" ]]; then
|
||||
matches+=("$(basename "$dir")")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Handle results
|
||||
if [[ ${#matches[@]} -eq 0 ]]; then
|
||||
# No match found - return the branch name path (will fail later with clear error)
|
||||
echo "$specs_dir/$branch_name"
|
||||
elif [[ ${#matches[@]} -eq 1 ]]; then
|
||||
# Exactly one match - perfect!
|
||||
echo "$specs_dir/${matches[0]}"
|
||||
else
|
||||
# Multiple matches - this shouldn't happen with proper naming convention
|
||||
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
|
||||
echo "Please ensure only one spec directory exists per numeric prefix." >&2
|
||||
echo "$specs_dir/$branch_name" # Return something to avoid breaking the script
|
||||
fi
|
||||
}
|
||||
|
||||
get_feature_paths() {
|
||||
local repo_root=$(get_repo_root)
|
||||
local current_branch=$(get_current_branch)
|
||||
local has_git_repo="false"
|
||||
|
||||
if has_git; then
|
||||
has_git_repo="true"
|
||||
fi
|
||||
|
||||
# Use prefix-based lookup to support multiple branches per spec
|
||||
local feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch")
|
||||
|
||||
cat <<EOF
|
||||
REPO_ROOT='$repo_root'
|
||||
CURRENT_BRANCH='$current_branch'
|
||||
HAS_GIT='$has_git_repo'
|
||||
FEATURE_DIR='$feature_dir'
|
||||
FEATURE_SPEC='$feature_dir/spec.md'
|
||||
IMPL_PLAN='$feature_dir/plan.md'
|
||||
TASKS='$feature_dir/tasks.md'
|
||||
RESEARCH='$feature_dir/research.md'
|
||||
DATA_MODEL='$feature_dir/data-model.md'
|
||||
QUICKSTART='$feature_dir/quickstart.md'
|
||||
CONTRACTS_DIR='$feature_dir/contracts'
|
||||
EOF
|
||||
}
|
||||
|
||||
check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||
check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN H1 context validator.
|
||||
# Before a sub-agent trusts pipeline-context.yaml, verify every referenced
|
||||
# artifact / trace file actually exists on disk, and (optionally) is not
|
||||
# staler than CASAN_CONTEXT_TTL_SECONDS relative to the context file itself.
|
||||
# Catches renamed/deleted/missing artifacts before they cause a silent bad read.
|
||||
#
|
||||
# Usage: context-validate.sh <pipeline-context.yaml>
|
||||
# Exit: 0 all good, 2 a referenced path is missing, 3 a referenced path is stale.
|
||||
|
||||
CTX="${1:-}"
|
||||
if [[ -z "$CTX" || ! -f "$CTX" ]]; then
|
||||
echo "Usage: context-validate.sh <pipeline-context.yaml>" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
CTX_DIR="$(cd "$(dirname "$CTX")" && pwd)"
|
||||
# Resolve paths relative to the repo root (3 levels up from this script).
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
python3 - "$CTX" "$PROJECT_ROOT" "${CASAN_CONTEXT_TTL_SECONDS:-0}" <<'PY'
|
||||
import os, re, sys
|
||||
|
||||
ctx, root, ttl = sys.argv[1], sys.argv[2], int(sys.argv[3])
|
||||
ctx_mtime = os.path.getmtime(ctx)
|
||||
missing, stale, checked = [], [], 0
|
||||
|
||||
with open(ctx, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
m = re.match(r"\s*(?:artifact|trace_file|path|data-model|spec|plan):\s*(\S+)", line)
|
||||
if not m:
|
||||
continue
|
||||
ref = m.group(1).strip().strip('"').strip("'")
|
||||
if ref in ("", "null", "<from", "pipeline-context>"):
|
||||
continue
|
||||
if "<" in ref or ref.endswith(">"):
|
||||
continue # unresolved template placeholder, not a concrete path
|
||||
cand = ref if os.path.isabs(ref) else os.path.join(root, ref)
|
||||
checked += 1
|
||||
if not os.path.exists(cand):
|
||||
missing.append(ref)
|
||||
continue
|
||||
if ttl > 0 and (ctx_mtime - os.path.getmtime(cand)) > ttl:
|
||||
stale.append(ref)
|
||||
|
||||
if missing:
|
||||
sys.stderr.write("CONTEXT_INVALID missing=%d: %s\n" % (len(missing), ", ".join(missing)))
|
||||
raise SystemExit(2)
|
||||
if stale:
|
||||
sys.stderr.write("CONTEXT_STALE stale=%d: %s\n" % (len(stale), ", ".join(stale)))
|
||||
raise SystemExit(3)
|
||||
print(f"CONTEXT_VALID checked={checked} all referenced artifacts present")
|
||||
PY
|
||||
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
JSON_MODE=false
|
||||
SHORT_NAME=""
|
||||
BRANCH_NUMBER=""
|
||||
ARGS=()
|
||||
i=1
|
||||
while [ $i -le $# ]; do
|
||||
arg="${!i}"
|
||||
case "$arg" in
|
||||
--json)
|
||||
JSON_MODE=true
|
||||
;;
|
||||
--short-name)
|
||||
if [ $((i + 1)) -gt $# ]; then
|
||||
echo 'Error: --short-name requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
i=$((i + 1))
|
||||
next_arg="${!i}"
|
||||
# Check if the next argument is another option (starts with --)
|
||||
if [[ "$next_arg" == --* ]]; then
|
||||
echo 'Error: --short-name requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
SHORT_NAME="$next_arg"
|
||||
;;
|
||||
--number)
|
||||
if [ $((i + 1)) -gt $# ]; then
|
||||
echo 'Error: --number requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
i=$((i + 1))
|
||||
next_arg="${!i}"
|
||||
if [[ "$next_arg" == --* ]]; then
|
||||
echo 'Error: --number requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
BRANCH_NUMBER="$next_arg"
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--json] [--short-name <name>] [--number N] <feature_description>"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --json Output in JSON format"
|
||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
||||
echo " --number N Specify branch number manually (overrides auto-detection)"
|
||||
echo " --help, -h Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
||||
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
ARGS+=("$arg")
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
FEATURE_DESCRIPTION="${ARGS[*]}"
|
||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
echo "Usage: $0 [--json] [--short-name <name>] [--number N] <feature_description>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Trim whitespace and validate description is not empty (e.g., user passed only whitespace)
|
||||
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
|
||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to find the repository root by searching for existing project markers
|
||||
find_repo_root() {
|
||||
local dir="$1"
|
||||
while [ "$dir" != "/" ]; do
|
||||
if [ -d "$dir/.git" ] || [ -d "$dir/.specify" ]; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
dir="$(dirname "$dir")"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Function to get highest number from specs directory
|
||||
get_highest_from_specs() {
|
||||
local specs_dir="$1"
|
||||
local highest=0
|
||||
|
||||
if [ -d "$specs_dir" ]; then
|
||||
for dir in "$specs_dir"/*; do
|
||||
[ -d "$dir" ] || continue
|
||||
dirname=$(basename "$dir")
|
||||
number=$(echo "$dirname" | grep -o '^[0-9]\+' || echo "0")
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to get highest number from git branches
|
||||
get_highest_from_branches() {
|
||||
local highest=0
|
||||
|
||||
# Get all branches (local and remote)
|
||||
branches=$(git branch -a 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$branches" ]; then
|
||||
while IFS= read -r branch; do
|
||||
# Clean branch name: remove leading markers and remote prefixes
|
||||
clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||')
|
||||
|
||||
# Extract feature number if branch matches pattern ###-*
|
||||
if echo "$clean_branch" | grep -q '^[0-9]\{3\}-'; then
|
||||
number=$(echo "$clean_branch" | grep -o '^[0-9]\{3\}' || echo "0")
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
fi
|
||||
fi
|
||||
done <<< "$branches"
|
||||
fi
|
||||
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to check existing branches (local and remote) and return next available number
|
||||
check_existing_branches() {
|
||||
local specs_dir="$1"
|
||||
|
||||
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
||||
git fetch --all --prune 2>/dev/null || true
|
||||
|
||||
# Get highest number from ALL branches (not just matching short name)
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
|
||||
# Get highest number from ALL specs (not just matching short name)
|
||||
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
||||
|
||||
# Take the maximum of both
|
||||
local max_num=$highest_branch
|
||||
if [ "$highest_spec" -gt "$max_num" ]; then
|
||||
max_num=$highest_spec
|
||||
fi
|
||||
|
||||
# Return next number
|
||||
echo $((max_num + 1))
|
||||
}
|
||||
|
||||
# Function to clean and format a branch name
|
||||
clean_branch_name() {
|
||||
local name="$1"
|
||||
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||
}
|
||||
|
||||
# Resolve repository root. Prefer git information when available, but fall back
|
||||
# to searching for repository markers so the workflow still functions in repositories that
|
||||
# were initialised with --no-git.
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
HAS_GIT=true
|
||||
else
|
||||
REPO_ROOT="$(find_repo_root "$SCRIPT_DIR")"
|
||||
if [ -z "$REPO_ROOT" ]; then
|
||||
echo "Error: Could not determine repository root. Please run this script from within the repository." >&2
|
||||
exit 1
|
||||
fi
|
||||
HAS_GIT=false
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
SPECS_DIR="$REPO_ROOT/specs"
|
||||
mkdir -p "$SPECS_DIR"
|
||||
|
||||
# Function to generate branch name with stop word filtering and length filtering
|
||||
generate_branch_name() {
|
||||
local description="$1"
|
||||
|
||||
# Common stop words to filter out
|
||||
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
|
||||
|
||||
# Convert to lowercase and split into words
|
||||
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
|
||||
|
||||
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
|
||||
local meaningful_words=()
|
||||
for word in $clean_name; do
|
||||
# Skip empty words
|
||||
[ -z "$word" ] && continue
|
||||
|
||||
# Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms)
|
||||
if ! echo "$word" | grep -qiE "$stop_words"; then
|
||||
if [ ${#word} -ge 3 ]; then
|
||||
meaningful_words+=("$word")
|
||||
elif echo "$description" | grep -q "\b${word^^}\b"; then
|
||||
# Keep short words if they appear as uppercase in original (likely acronyms)
|
||||
meaningful_words+=("$word")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# If we have meaningful words, use first 3-4 of them
|
||||
if [ ${#meaningful_words[@]} -gt 0 ]; then
|
||||
local max_words=3
|
||||
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
|
||||
|
||||
local result=""
|
||||
local count=0
|
||||
for word in "${meaningful_words[@]}"; do
|
||||
if [ $count -ge $max_words ]; then break; fi
|
||||
if [ -n "$result" ]; then result="$result-"; fi
|
||||
result="$result$word"
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo "$result"
|
||||
else
|
||||
# Fallback to original logic if no meaningful words found
|
||||
local cleaned=$(clean_branch_name "$description")
|
||||
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
|
||||
fi
|
||||
}
|
||||
|
||||
# Generate branch name
|
||||
if [ -n "$SHORT_NAME" ]; then
|
||||
# Use provided short name, just clean it up
|
||||
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
|
||||
else
|
||||
# Generate from description with smart filtering
|
||||
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
|
||||
fi
|
||||
|
||||
# Determine branch number
|
||||
if [ -z "$BRANCH_NUMBER" ]; then
|
||||
if [ "$HAS_GIT" = true ]; then
|
||||
# Check existing branches on remotes
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
||||
else
|
||||
# Fall back to local directory check
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
|
||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names
|
||||
# Validate and truncate if necessary
|
||||
MAX_BRANCH_LENGTH=244
|
||||
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
|
||||
# Calculate how much we need to trim from suffix
|
||||
# Account for: feature number (3) + hyphen (1) = 4 chars
|
||||
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - 4))
|
||||
|
||||
# Truncate suffix at word boundary if possible
|
||||
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
||||
# Remove trailing hyphen if truncation created one
|
||||
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
|
||||
|
||||
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
||||
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
|
||||
|
||||
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
||||
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
||||
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
||||
fi
|
||||
|
||||
if [ "$HAS_GIT" = true ]; then
|
||||
if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then
|
||||
# Check if branch already exists
|
||||
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
||||
exit 1
|
||||
else
|
||||
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
|
||||
fi
|
||||
|
||||
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md"
|
||||
SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||
if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi
|
||||
|
||||
# Set the SPECIFY_FEATURE environment variable for the current session
|
||||
export SPECIFY_FEATURE="$BRANCH_NAME"
|
||||
|
||||
if $JSON_MODE; then
|
||||
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$BRANCH_NAME" "$SPEC_FILE" "$FEATURE_NUM"
|
||||
else
|
||||
echo "BRANCH_NAME: $BRANCH_NAME"
|
||||
echo "SPEC_FILE: $SPEC_FILE"
|
||||
echo "FEATURE_NUM: $FEATURE_NUM"
|
||||
echo "SPECIFY_FEATURE environment variable set to: $BRANCH_NAME"
|
||||
fi
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN Level 5 drift detector.
|
||||
# Usage:
|
||||
# drift-detect.sh <golden-file> <candidate-file> <report-json>
|
||||
|
||||
GOLDEN="${1:-}"
|
||||
CANDIDATE="${2:-}"
|
||||
REPORT="${3:-}"
|
||||
|
||||
if [[ -z "$GOLDEN" || -z "$CANDIDATE" || -z "$REPORT" ]]; then
|
||||
echo "Usage: drift-detect.sh <golden-file> <candidate-file> <report-json>" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
mkdir -p "$(dirname "$REPORT")" "$PROJECT_ROOT/.specify/logs/level5"
|
||||
|
||||
python3 - "$GOLDEN" "$CANDIDATE" "$REPORT" <<'PY'
|
||||
import difflib
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
golden_path = pathlib.Path(sys.argv[1])
|
||||
candidate_path = pathlib.Path(sys.argv[2])
|
||||
report_path = pathlib.Path(sys.argv[3])
|
||||
|
||||
golden = golden_path.read_text(encoding="utf-8")
|
||||
candidate = candidate_path.read_text(encoding="utf-8")
|
||||
|
||||
similarity = difflib.SequenceMatcher(None, golden, candidate).ratio()
|
||||
length_delta = abs(len(candidate) - len(golden)) / max(len(golden), 1)
|
||||
|
||||
status = "pass"
|
||||
action = "allow"
|
||||
if similarity < 0.70 or length_delta > 0.50:
|
||||
status = "fail"
|
||||
action = "block_or_fallback"
|
||||
elif similarity < 0.85 or length_delta > 0.30:
|
||||
status = "warn"
|
||||
action = "require_review"
|
||||
|
||||
report = {
|
||||
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"harness": "L5-drift-detection",
|
||||
"status": status,
|
||||
"action": action,
|
||||
"similarity_ratio": round(similarity, 4),
|
||||
"length_delta_ratio": round(length_delta, 4),
|
||||
"golden_hash": hashlib.sha256(golden.encode()).hexdigest(),
|
||||
"candidate_hash": hashlib.sha256(candidate.encode()).hexdigest(),
|
||||
"golden_file": str(golden_path),
|
||||
"candidate_file": str(candidate_path),
|
||||
}
|
||||
|
||||
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"DRIFT_{status.upper()} similarity={report['similarity_ratio']} length_delta={report['length_delta_ratio']} report={report_path}")
|
||||
|
||||
if status == "fail":
|
||||
raise SystemExit(2)
|
||||
PY
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN H5 Governance Harness
|
||||
# Usage:
|
||||
# governance-check.sh <input-file> <output-file> [action-name]
|
||||
#
|
||||
# Non-interactive by default. High-risk actions are denied unless:
|
||||
# CASAN_APPROVAL_DECISION=approve CASAN_APPROVER=<name>
|
||||
|
||||
INPUT_FILE="${1:-}"
|
||||
OUTPUT_FILE="${2:-}"
|
||||
ACTION_NAME="${3:-agent_step}"
|
||||
|
||||
if [[ -z "$INPUT_FILE" || -z "$OUTPUT_FILE" ]]; then
|
||||
echo "Usage: governance-check.sh <input-file> <output-file> [action-name]" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
LOG_DIR="$PROJECT_ROOT/.specify/logs"
|
||||
TRACE_DIR="$LOG_DIR/trace"
|
||||
AUDIT_DIR="$LOG_DIR/audit"
|
||||
mkdir -p "$TRACE_DIR" "$AUDIT_DIR" "$(dirname "$OUTPUT_FILE")"
|
||||
|
||||
if [[ ! -f "$INPUT_FILE" ]]; then
|
||||
echo "GOVERNANCE_DENIED: input file not found: $INPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
timestamp() {
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ"
|
||||
}
|
||||
|
||||
new_trace_id() {
|
||||
if command -v uuidgen >/dev/null 2>&1; then
|
||||
uuidgen | tr '[:upper:]' '[:lower:]'
|
||||
else
|
||||
printf 'trace-%s-%s\n' "$(date +%s)" "$$"
|
||||
fi
|
||||
}
|
||||
|
||||
hash_text() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum | awk '{print $1}'
|
||||
else
|
||||
shasum -a 256 | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null || sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
TRACE_ID="$(new_trace_id)"
|
||||
TIMESTAMP="$(timestamp)"
|
||||
INPUT="$(cat "$INPUT_FILE")"
|
||||
LOWER_INPUT="$(printf '%s' "$INPUT" | tr '[:upper:]' '[:lower:]')"
|
||||
ACTOR="${CASAN_ACTOR:-developer}"
|
||||
APPROVER="${CASAN_APPROVER:-}"
|
||||
APPROVAL_DECISION="${CASAN_APPROVAL_DECISION:-auto}"
|
||||
AUDIT_LOG="$AUDIT_DIR/audit.jsonl"
|
||||
|
||||
RISK_LEVEL="low"
|
||||
REASONS=()
|
||||
|
||||
case "$ACTION_NAME" in
|
||||
deploy|launch|write_code|write_file|migration|db_write|external_api|tool_call)
|
||||
RISK_LEVEL="medium"
|
||||
REASONS+=("sensitive-action:$ACTION_NAME")
|
||||
;;
|
||||
esac
|
||||
|
||||
if printf '%s' "$LOWER_INPUT" | grep -Eq "(delete|drop table|password|api[_-]?key|secret|token|credential|migration|deploy|external api|shutdown|dump database)"; then
|
||||
RISK_LEVEL="high"
|
||||
REASONS+=("high-risk-content")
|
||||
elif printf '%s' "$LOWER_INPUT" | grep -Eq "(internal|config|system|policy|permission)"; then
|
||||
[[ "$RISK_LEVEL" == "low" ]] && RISK_LEVEL="medium"
|
||||
REASONS+=("medium-risk-content")
|
||||
fi
|
||||
|
||||
APPROVAL_STATUS="auto_approved"
|
||||
DECISION="approved"
|
||||
|
||||
if [[ "$RISK_LEVEL" == "medium" ]]; then
|
||||
APPROVAL_STATUS="policy_auto_approved_with_audit"
|
||||
fi
|
||||
|
||||
if [[ "$RISK_LEVEL" == "high" ]]; then
|
||||
if [[ "$APPROVAL_DECISION" == "approve" && -n "$APPROVER" ]]; then
|
||||
if [[ "$APPROVER" == "$ACTOR" ]]; then
|
||||
# Separation of duties: the submitter may not approve their own action.
|
||||
APPROVAL_STATUS="separation_of_duties_violation"
|
||||
DECISION="denied"
|
||||
REASONS+=("separation-of-duties:actor-equals-approver")
|
||||
else
|
||||
APPROVAL_STATUS="human_approved"
|
||||
DECISION="approved"
|
||||
fi
|
||||
else
|
||||
APPROVAL_STATUS="approval_required"
|
||||
DECISION="denied"
|
||||
fi
|
||||
fi
|
||||
|
||||
INPUT_HASH="$(printf '%s' "$INPUT" | hash_text)"
|
||||
OUTPUT_CONTENT="$INPUT"
|
||||
OUTPUT_HASH="$(printf '%s' "$OUTPUT_CONTENT" | hash_text)"
|
||||
PREV_HASH=""
|
||||
if [[ -s "$AUDIT_LOG" ]]; then
|
||||
PREV_HASH="$(tail -n 1 "$AUDIT_LOG" | sed -n 's/.*"record_hash":"\([^"]*\)".*/\1/p')"
|
||||
fi
|
||||
|
||||
REASONS_JSON="$(printf '%s\n' "${REASONS[@]:-}" | python3 -c 'import json,sys; print(json.dumps([x for x in sys.stdin.read().splitlines() if x]))')"
|
||||
# approver and output_hash are part of the hashed core so they cannot be
|
||||
# silently mutated after the fact.
|
||||
RECORD_CORE="$(printf '%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s' "$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" "$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH")"
|
||||
RECORD_HASH="$(printf '%s' "$RECORD_CORE" | hash_text)"
|
||||
|
||||
TRACE_FILE="$TRACE_DIR/governance-$TRACE_ID.json"
|
||||
cat > "$TRACE_FILE" <<EOF
|
||||
{
|
||||
"trace_id": "$TRACE_ID",
|
||||
"timestamp": "$TIMESTAMP",
|
||||
"harness": "H5-governance",
|
||||
"action": "$ACTION_NAME",
|
||||
"actor": "$ACTOR",
|
||||
"risk_level": "$RISK_LEVEL",
|
||||
"decision": "$DECISION",
|
||||
"approval_status": "$APPROVAL_STATUS",
|
||||
"approver": "$APPROVER",
|
||||
"reasons": $REASONS_JSON,
|
||||
"input_hash": "$INPUT_HASH",
|
||||
"output_hash": "$OUTPUT_HASH",
|
||||
"previous_record_hash": "$PREV_HASH",
|
||||
"record_hash": "$RECORD_HASH"
|
||||
}
|
||||
EOF
|
||||
|
||||
printf '{"timestamp":"%s","trace_id":"%s","harness":"H5-governance","action":"%s","actor":"%s","risk_level":"%s","decision":"%s","approval_status":"%s","approver":"%s","input_hash":"%s","output_hash":"%s","previous_record_hash":"%s","record_hash":"%s"}\n' \
|
||||
"$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" "$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" >> "$AUDIT_LOG"
|
||||
|
||||
# --- External anchor: cryptographically sign the new chain head ---
|
||||
# A re-forged chain (recomputed hashes) changes the head; without the private
|
||||
# key the attacker cannot produce a matching signature, so verification fails.
|
||||
# Production note: the private key must live off-repo (KMS/HSM). It is local
|
||||
# here only for self-contained demonstration.
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
# Private signing key lives OFF-REPO (default ~/.casan/audit-keys); only the
|
||||
# public key is committed. Production: replace with KMS/HSM.
|
||||
PUB_DIR="$PROJECT_ROOT/.specify/level5/central-governance"
|
||||
PRIV_DIR="${CASAN_AUDIT_KEY_DIR:-$HOME/.casan/audit-keys}"
|
||||
AUDIT_PRIV="$PRIV_DIR/audit-private.pem"
|
||||
AUDIT_PUB="$PUB_DIR/audit-public.pem"
|
||||
mkdir -p "$PUB_DIR" "$PRIV_DIR"
|
||||
if [[ ! -f "$AUDIT_PRIV" ]]; then
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$AUDIT_PRIV" 2>/dev/null
|
||||
chmod 600 "$AUDIT_PRIV"
|
||||
openssl rsa -in "$AUDIT_PRIV" -pubout -out "$AUDIT_PUB" 2>/dev/null
|
||||
fi
|
||||
printf '%s' "$RECORD_HASH" > "$AUDIT_DIR/audit-head.txt"
|
||||
openssl dgst -sha256 -sign "$AUDIT_PRIV" -out "$AUDIT_DIR/audit-head.sig" "$AUDIT_DIR/audit-head.txt" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [[ "$DECISION" != "approved" ]]; then
|
||||
: > "$OUTPUT_FILE"
|
||||
echo "GOVERNANCE_DENIED trace_id=$TRACE_ID risk=$RISK_LEVEL approval_status=$APPROVAL_STATUS" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
printf '%s\n' "$OUTPUT_CONTENT" > "$OUTPUT_FILE"
|
||||
echo "GOVERNANCE_APPROVED trace_id=$TRACE_ID risk=$RISK_LEVEL approval_status=$APPROVAL_STATUS output=$OUTPUT_FILE"
|
||||
@@ -0,0 +1,25 @@
|
||||
INPUT
|
||||
↓
|
||||
security-check.sh input (H4: prompt injection, PII, secret)
|
||||
↓
|
||||
governance-check.sh (H5: risk, approval, hash-chain audit)
|
||||
↓
|
||||
agent-metrics.sh (H6: latency, tokens, cost, retry, status, alert)
|
||||
↓
|
||||
security-check.sh output (H4: output redaction/filter)
|
||||
↓
|
||||
OUTPUT
|
||||
↓
|
||||
LOG + TRACE + METRICS + AUDIT
|
||||
|
||||
Unified wrapper:
|
||||
|
||||
```bash
|
||||
.specify/scripts/bash/casan-harness.sh input.txt output.txt agent_step
|
||||
```
|
||||
|
||||
Verification:
|
||||
|
||||
```bash
|
||||
bash .specify/tests/run-casan4-harness-tests.sh
|
||||
```
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN H6 hallucination signal detector.
|
||||
|
||||
Reads the quoted keyword markers from hallucination-tracking.yaml plus a set of
|
||||
generic uncertainty markers, scans the agent output, and reports how many
|
||||
hallucination signals were found. This turns hallucination-tracking.yaml from
|
||||
dead config into a real, populated metric written to metrics.jsonl.
|
||||
|
||||
Usage: hallucination-scan.py <hallucination-tracking.yaml> <output-file>
|
||||
Output (stdout): line 1 = integer signal count, line 2 = JSON list of matches.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
GENERIC_MARKERS = [
|
||||
"maybe", "might be incorrect", "i am not sure", "uncertain",
|
||||
"i think", "probably", "as far as i know",
|
||||
]
|
||||
|
||||
|
||||
def load_keywords(path):
|
||||
keywords = []
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
# Quoted list items are the hallucination keyword markers
|
||||
# (unquoted list items are structured signal names, not text).
|
||||
m = re.match(r'\s*-\s*"(.+)"\s*$', line)
|
||||
if m:
|
||||
keywords.append(m.group(1))
|
||||
except OSError:
|
||||
pass
|
||||
return keywords
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print(0)
|
||||
print("[]")
|
||||
return
|
||||
keywords = load_keywords(sys.argv[1]) + GENERIC_MARKERS
|
||||
try:
|
||||
with open(sys.argv[2], encoding="utf-8") as fh:
|
||||
text = fh.read().lower()
|
||||
except OSError:
|
||||
print(0)
|
||||
print("[]")
|
||||
return
|
||||
matched = []
|
||||
for kw in keywords:
|
||||
k = kw.lower()
|
||||
if not k:
|
||||
continue
|
||||
count = text.count(k)
|
||||
if count:
|
||||
matched.append({"marker": kw, "count": count})
|
||||
total = sum(m["count"] for m in matched)
|
||||
print(total)
|
||||
print(json.dumps(matched))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN Level 5 provider usage telemetry importer.
|
||||
# Usage:
|
||||
# import-provider-telemetry.sh <provider-usage-json>
|
||||
|
||||
INPUT_JSON="${1:-}"
|
||||
if [[ -z "$INPUT_JSON" || ! -f "$INPUT_JSON" ]]; then
|
||||
echo "Usage: import-provider-telemetry.sh <provider-usage-json>" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
LOG_DIR="$PROJECT_ROOT/.specify/logs/level5"
|
||||
OUT="$LOG_DIR/provider-usage.jsonl"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
python3 - "$INPUT_JSON" "$OUT" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
src, out = sys.argv[1], sys.argv[2]
|
||||
data = json.load(open(src, encoding="utf-8"))
|
||||
required = ["provider", "model", "run_id", "step", "input_tokens", "output_tokens", "total_tokens", "cost_usd", "latency_ms", "status"]
|
||||
missing = [key for key in required if key not in data]
|
||||
if missing:
|
||||
raise SystemExit(f"PROVIDER_USAGE_INVALID missing={missing}")
|
||||
record = {
|
||||
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"harness": "L5-provider-telemetry",
|
||||
**data,
|
||||
}
|
||||
with open(out, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record) + "\n")
|
||||
print(f"PROVIDER_TELEMETRY_IMPORTED provider={data['provider']} model={data['model']} total_tokens={data['total_tokens']} cost_usd={data['cost_usd']} output={out}")
|
||||
PY
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN Level 5 fallback runner.
|
||||
# Usage:
|
||||
# model-fallback.sh <output-file> --primary '<cmd>' --fallback '<cmd>'
|
||||
|
||||
OUTPUT_FILE="${1:-}"
|
||||
shift || true
|
||||
|
||||
PRIMARY_CMD=""
|
||||
FALLBACK_CMD=""
|
||||
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--primary)
|
||||
PRIMARY_CMD="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--fallback)
|
||||
FALLBACK_CMD="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$OUTPUT_FILE" || -z "$PRIMARY_CMD" || -z "$FALLBACK_CMD" ]]; then
|
||||
echo "Usage: model-fallback.sh <output-file> --primary '<cmd>' --fallback '<cmd>'" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
LOG_DIR="$PROJECT_ROOT/.specify/logs/level5"
|
||||
mkdir -p "$LOG_DIR" "$(dirname "$OUTPUT_FILE")"
|
||||
FALLBACK_LOG="$LOG_DIR/fallback.jsonl"
|
||||
TRACE_ID="$(uuidgen 2>/dev/null | tr '[:upper:]' '[:lower:]' || printf 'fallback-%s-%s' "$(date +%s)" "$$")"
|
||||
TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
|
||||
TMP_PRIMARY="$(mktemp)"
|
||||
TMP_FALLBACK="$(mktemp)"
|
||||
|
||||
set +e
|
||||
bash -c "$PRIMARY_CMD" > "$TMP_PRIMARY" 2>&1
|
||||
PRIMARY_RC=$?
|
||||
set -e
|
||||
|
||||
ROUTE="primary"
|
||||
FINAL_RC="$PRIMARY_RC"
|
||||
if [[ "$PRIMARY_RC" -eq 0 && -s "$TMP_PRIMARY" ]]; then
|
||||
cp "$TMP_PRIMARY" "$OUTPUT_FILE"
|
||||
else
|
||||
ROUTE="fallback"
|
||||
set +e
|
||||
bash -c "$FALLBACK_CMD" > "$TMP_FALLBACK" 2>&1
|
||||
FALLBACK_RC=$?
|
||||
set -e
|
||||
FINAL_RC="$FALLBACK_RC"
|
||||
cp "$TMP_FALLBACK" "$OUTPUT_FILE"
|
||||
fi
|
||||
|
||||
printf '{"timestamp":"%s","trace_id":"%s","harness":"L5-model-fallback","primary_exit":%s,"route":"%s","final_exit":%s,"output":"%s"}\n' \
|
||||
"$TIMESTAMP" "$TRACE_ID" "$PRIMARY_RC" "$ROUTE" "$FINAL_RC" "$OUTPUT_FILE" >> "$FALLBACK_LOG"
|
||||
|
||||
echo "FALLBACK_ROUTE route=$ROUTE primary_exit=$PRIMARY_RC final_exit=$FINAL_RC output=$OUTPUT_FILE"
|
||||
exit "$FINAL_RC"
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN H4 PII masker driven by .specify/security/pii-rules.yaml.
|
||||
|
||||
Reads content on stdin, applies every `action: mask` rule from the rules
|
||||
file, and writes the masked content to stdout. Type-specific replacement
|
||||
tokens are preserved so downstream evidence stays stable
|
||||
(***MASKED_EMAIL***, ***MASKED_PHONE***, ***MASKED_ID***).
|
||||
|
||||
This makes pii-rules.yaml the source of truth for PII masking instead of
|
||||
dead config: editing/removing a rule changes runtime behavior.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
|
||||
REPLACEMENT_BY_TYPE = {
|
||||
"email": "***MASKED_EMAIL***",
|
||||
"phone": "***MASKED_PHONE***",
|
||||
"personal_id": "***MASKED_ID***",
|
||||
"address": "***MASKED_ADDRESS***",
|
||||
}
|
||||
|
||||
|
||||
def load_rules(path):
|
||||
rules, cur = [], {}
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for raw in fh:
|
||||
s = raw.strip()
|
||||
m = re.match(r"-\s*id:\s*(\S+)", s)
|
||||
if m:
|
||||
if cur:
|
||||
rules.append(cur)
|
||||
cur = {"id": m.group(1)}
|
||||
continue
|
||||
m = re.match(r'type:\s*"?([^"\s]+)"?', s)
|
||||
if m:
|
||||
cur["type"] = m.group(1)
|
||||
continue
|
||||
m = re.match(r'regex:\s*"(.*)"\s*$', s)
|
||||
if m:
|
||||
# YAML double-quoted: collapse \\ -> \ to recover the real regex.
|
||||
cur["regex"] = m.group(1).replace("\\\\", "\\")
|
||||
continue
|
||||
m = re.match(r"action:\s*(\S+)", s)
|
||||
if m:
|
||||
cur["action"] = m.group(1)
|
||||
continue
|
||||
if cur:
|
||||
rules.append(cur)
|
||||
return rules
|
||||
|
||||
|
||||
def main():
|
||||
data = sys.stdin.read()
|
||||
if len(sys.argv) < 2:
|
||||
sys.stdout.write(data)
|
||||
return
|
||||
try:
|
||||
rules = load_rules(sys.argv[1])
|
||||
except OSError:
|
||||
sys.stdout.write(data)
|
||||
return
|
||||
for rule in rules:
|
||||
if rule.get("action") != "mask" or "regex" not in rule:
|
||||
continue
|
||||
token = REPLACEMENT_BY_TYPE.get(rule.get("type", ""), "***MASKED***")
|
||||
try:
|
||||
data = re.sub(rule["regex"], token, data)
|
||||
except re.error:
|
||||
continue
|
||||
sys.stdout.write(data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Return '<total_tokens> <cost_usd>' for the provider-telemetry record that
|
||||
matches the given step, or print nothing if there is no genuine match.
|
||||
|
||||
Never falls back to an arbitrary record — reusing one sample's cost across
|
||||
every step would misrepresent an estimate as real per-step billing.
|
||||
|
||||
Usage: provider-cost-lookup.py <provider-usage.jsonl> <step-name>
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
sys.exit(0)
|
||||
path, step = sys.argv[1], sys.argv[2]
|
||||
match = None
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
rec = json.loads(line)
|
||||
if rec.get("step") == step:
|
||||
match = rec # last matching record wins
|
||||
except OSError:
|
||||
sys.exit(0)
|
||||
if match is not None:
|
||||
print(f"{match.get('total_tokens', 0)} {match.get('cost_usd', 0)}")
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN Level 5 rollback transaction recorder.
|
||||
# Usage:
|
||||
# rollback-manager.sh record <action> <rollback-command>
|
||||
# rollback-manager.sh checkpoint <file> # back up a file; records a REAL restore command
|
||||
# rollback-manager.sh execute <transaction-id>
|
||||
|
||||
MODE="${1:-}"
|
||||
ACTION="${2:-}"
|
||||
ROLLBACK_COMMAND="${3:-}"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
LOG_DIR="$PROJECT_ROOT/.specify/logs/level5"
|
||||
BACKUP_DIR="$LOG_DIR/rollback-backups"
|
||||
TX_LOG="$LOG_DIR/rollback-transactions.jsonl"
|
||||
mkdir -p "$LOG_DIR" "$BACKUP_DIR"
|
||||
|
||||
# checkpoint: snapshot a real file and record a real restore command so a later
|
||||
# `execute` genuinely undoes any change (not a marker write).
|
||||
if [[ "$MODE" == "checkpoint" ]]; then
|
||||
TARGET="$ACTION"
|
||||
if [[ -z "$TARGET" || ! -f "$TARGET" ]]; then
|
||||
echo "Usage: rollback-manager.sh checkpoint <existing-file>" >&2
|
||||
exit 64
|
||||
fi
|
||||
TX_ID="$(uuidgen 2>/dev/null | tr '[:upper:]' '[:lower:]' || printf 'tx-%s-%s' "$(date +%s)" "$$")"
|
||||
BACKUP="$BACKUP_DIR/$TX_ID.bak"
|
||||
cp "$TARGET" "$BACKUP"
|
||||
ABS_TARGET="$(cd "$(dirname "$TARGET")" && pwd)/$(basename "$TARGET")"
|
||||
RESTORE_CMD="cp '$BACKUP' '$ABS_TARGET'"
|
||||
TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
python3 - "$TX_LOG" "$TIMESTAMP" "$TX_ID" "$ABS_TARGET" "$RESTORE_CMD" "$BACKUP" <<'PY'
|
||||
import json, sys
|
||||
log, ts, tx, target, cmd, backup = sys.argv[1:]
|
||||
rec = {"timestamp": ts, "transaction_id": tx, "action": "checkpoint",
|
||||
"target": target, "backup": backup, "rollback_command": cmd, "status": "recorded"}
|
||||
open(log, "a", encoding="utf-8").write(json.dumps(rec) + "\n")
|
||||
PY
|
||||
echo "ROLLBACK_CHECKPOINT transaction_id=$TX_ID target=$ABS_TARGET"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "record" ]]; then
|
||||
if [[ -z "$ACTION" || -z "$ROLLBACK_COMMAND" ]]; then
|
||||
echo "Usage: rollback-manager.sh record <action> <rollback-command>" >&2
|
||||
exit 64
|
||||
fi
|
||||
TX_ID="$(uuidgen 2>/dev/null | tr '[:upper:]' '[:lower:]' || printf 'tx-%s-%s' "$(date +%s)" "$$")"
|
||||
TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
printf '{"timestamp":"%s","transaction_id":"%s","action":"%s","rollback_command":"%s","status":"recorded"}\n' \
|
||||
"$TIMESTAMP" "$TX_ID" "$ACTION" "$ROLLBACK_COMMAND" >> "$TX_LOG"
|
||||
echo "ROLLBACK_RECORDED transaction_id=$TX_ID"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "execute" ]]; then
|
||||
TX_ID="$ACTION"
|
||||
if [[ -z "$TX_ID" || ! -f "$TX_LOG" ]]; then
|
||||
echo "ROLLBACK_NOT_FOUND transaction_id=$TX_ID" >&2
|
||||
exit 1
|
||||
fi
|
||||
COMMAND="$(python3 - "$TX_LOG" "$TX_ID" <<'PY'
|
||||
import json, sys
|
||||
for line in open(sys.argv[1], encoding="utf-8"):
|
||||
rec=json.loads(line)
|
||||
if rec.get("transaction_id")==sys.argv[2]:
|
||||
print(rec.get("rollback_command",""))
|
||||
break
|
||||
PY
|
||||
)"
|
||||
if [[ -z "$COMMAND" ]]; then
|
||||
echo "ROLLBACK_NOT_FOUND transaction_id=$TX_ID" >&2
|
||||
exit 1
|
||||
fi
|
||||
bash -c "$COMMAND"
|
||||
TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
printf '{"timestamp":"%s","transaction_id":"%s","status":"rolled_back"}\n' "$TIMESTAMP" "$TX_ID" >> "$TX_LOG"
|
||||
echo "ROLLBACK_EXECUTED transaction_id=$TX_ID"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Usage: rollback-manager.sh record|execute ..." >&2
|
||||
exit 64
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN H4 Security Harness
|
||||
# Usage:
|
||||
# security-check.sh <input-file> <output-file> [input|output]
|
||||
#
|
||||
# input mode: blocks prompt injection / critical secrets, masks PII, writes safe prompt.
|
||||
# output mode: redacts PII/secrets from generated output, flags risky language, writes safe output.
|
||||
|
||||
INPUT_FILE="${1:-}"
|
||||
OUTPUT_FILE="${2:-}"
|
||||
MODE="${3:-input}"
|
||||
|
||||
if [[ -z "$INPUT_FILE" || -z "$OUTPUT_FILE" ]]; then
|
||||
echo "Usage: security-check.sh <input-file> <output-file> [input|output]" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
LOG_DIR="$PROJECT_ROOT/.specify/logs"
|
||||
TRACE_DIR="$LOG_DIR/trace"
|
||||
AUDIT_DIR="$LOG_DIR/audit"
|
||||
SECURITY_DIR="$PROJECT_ROOT/.specify/security"
|
||||
|
||||
mkdir -p "$TRACE_DIR" "$AUDIT_DIR" "$(dirname "$OUTPUT_FILE")"
|
||||
|
||||
if [[ ! -f "$INPUT_FILE" ]]; then
|
||||
echo "SECURITY_BLOCKED: input file not found: $INPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
timestamp() {
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ"
|
||||
}
|
||||
|
||||
new_trace_id() {
|
||||
if command -v uuidgen >/dev/null 2>&1; then
|
||||
uuidgen | tr '[:upper:]' '[:lower:]'
|
||||
else
|
||||
printf 'trace-%s-%s\n' "$(date +%s)" "$$"
|
||||
fi
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null || sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
hash_text() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum | awk '{print $1}'
|
||||
else
|
||||
shasum -a 256 | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
# Normalize text to defeat trivial injection bypasses:
|
||||
# lowercase, fold common leetspeak to letters, collapse punctuation/whitespace.
|
||||
# Used ONLY for injection/jailbreak phrase matching, never for PII/secret regexes.
|
||||
normalize_for_match() {
|
||||
printf '%s' "$1" \
|
||||
| tr '[:upper:]' '[:lower:]' \
|
||||
| tr '013457@$' 'oieastas' \
|
||||
| tr -c 'a-z0-9' ' ' \
|
||||
| tr -s ' '
|
||||
}
|
||||
|
||||
load_yaml_values() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
[[ -f "$file" ]] || return 0
|
||||
python3 - "$file" "$key" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
path, key = sys.argv[1], sys.argv[2]
|
||||
pattern = re.compile(rf'^\s*{re.escape(key)}:\s*"([^"]+)"\s*$')
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
match = pattern.search(line)
|
||||
if match:
|
||||
print(match.group(1))
|
||||
PY
|
||||
}
|
||||
|
||||
TRACE_ID="$(new_trace_id)"
|
||||
TIMESTAMP="$(timestamp)"
|
||||
CONTENT="$(cat "$INPUT_FILE")"
|
||||
STATUS="pass"
|
||||
ACTION="allow"
|
||||
RISK_LEVEL="low"
|
||||
MATCHED_RULES=()
|
||||
|
||||
BLOCK_PATTERNS=(
|
||||
"ignore (all |any )?(previous|prior|above|the above|earlier) (instruction|instructions|rule|rules|prompt|prompts|guideline|guidelines)"
|
||||
"ignore system instruction"
|
||||
"disregard (all |any |the )?.*(rule|instruction|previous|prior|above|earlier|policy|guideline)"
|
||||
"forget (all |everything |the |your )?.*(instruction|rule|previous|prior|above)"
|
||||
"bypass (the )?(system|policy|policies|filter|guardrail|guardrails|rule|rules|safety|restriction|restrictions)"
|
||||
"override (the )?(policy|policies|rule|rules|system|instruction|instructions|guardrail|safety)"
|
||||
"you are now"
|
||||
"act as (an? )?(admin|root|developer|unrestricted|unfiltered|different|evil|dan)"
|
||||
"act without (any )?(restriction|restrictions|rule|rules|limit|limits|filter|guardrail)"
|
||||
"pretend (to be|you are|you're) (system|not|no longer|an unrestricted)"
|
||||
"do anything now"
|
||||
"enable (dan|developer mode|jailbreak)"
|
||||
"developer mode"
|
||||
"reveal (the )?(system|hidden|internal|original) (prompt|instruction|instructions)"
|
||||
"show (the )?(hidden|internal|system) (instruction|instructions|prompt)"
|
||||
)
|
||||
|
||||
while IFS= read -r pattern; do
|
||||
[[ -n "$pattern" ]] && BLOCK_PATTERNS+=("$pattern")
|
||||
done < <(load_yaml_values "$SECURITY_DIR/prompt-filter.yaml" "pattern")
|
||||
|
||||
APPROVAL_PATTERNS=(
|
||||
"delete[[:space:]].*"
|
||||
"drop table"
|
||||
"shutdown system"
|
||||
"export secrets"
|
||||
"dump database"
|
||||
)
|
||||
|
||||
ALERT_PATTERNS=(
|
||||
"show all data"
|
||||
"internal prompt"
|
||||
"system message"
|
||||
"hidden instruction"
|
||||
)
|
||||
|
||||
EMAIL_REGEX='[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'
|
||||
PHONE_REGEX='(\+?[0-9][0-9 .-]{8,}[0-9])'
|
||||
PERSONAL_ID_REGEX='\b[0-9]{9,12}\b'
|
||||
CREDIT_CARD_REGEX='\b([0-9]{4}[- ]?){3}[0-9]{4}\b'
|
||||
SECRET_REGEX='(API[_-]?KEY|ACCESS[_-]?TOKEN|REFRESH[_-]?TOKEN|PASSWORD|JWT[_-]?SECRET|SECRET)[[:space:]]*[:=][[:space:]]*[^[:space:]]+'
|
||||
PRIVATE_KEY_REGEX='-----BEGIN (RSA |EC )?PRIVATE KEY-----'
|
||||
DB_CONN_REGEX='(postgres|mysql|mongodb)://[^@[:space:]]+@'
|
||||
AWS_KEY_REGEX='AKIA[0-9A-Z]{16}'
|
||||
|
||||
while IFS= read -r regex; do
|
||||
case "$regex" in
|
||||
*API*|*TOKEN*|*PASSWORD*|*SECRET*)
|
||||
regex="${regex//\\s/[[:space:]]}"
|
||||
regex="${regex//\\S/[^[:space:]]}"
|
||||
SECRET_REGEX="$regex"
|
||||
;;
|
||||
esac
|
||||
done < <(load_yaml_values "$SECURITY_DIR/output-policy.yaml" "regex")
|
||||
|
||||
lower_content="$(printf '%s' "$CONTENT" | tr '[:upper:]' '[:lower:]')"
|
||||
NORM_CONTENT="$(normalize_for_match "$CONTENT")"
|
||||
|
||||
# Matches a pattern against either the raw (case-insensitive) or the
|
||||
# normalization-folded content, so leetspeak/whitespace/punctuation
|
||||
# obfuscation cannot slip past a phrase blocklist.
|
||||
match_either() {
|
||||
local pattern="$1"
|
||||
printf '%s' "$CONTENT" | grep -Eiq -- "$pattern" \
|
||||
|| printf '%s' "$NORM_CONTENT" | grep -Eq -- "$pattern"
|
||||
}
|
||||
|
||||
if [[ "$MODE" == "input" ]]; then
|
||||
for pattern in "${BLOCK_PATTERNS[@]}"; do
|
||||
if match_either "$pattern"; then
|
||||
STATUS="blocked"
|
||||
ACTION="block"
|
||||
RISK_LEVEL="high"
|
||||
MATCHED_RULES+=("prompt-injection:$pattern")
|
||||
fi
|
||||
done
|
||||
|
||||
if printf '%s' "$CONTENT" | grep -Eq -- "$CREDIT_CARD_REGEX"; then
|
||||
STATUS="blocked"
|
||||
ACTION="block"
|
||||
RISK_LEVEL="high"
|
||||
MATCHED_RULES+=("pii-credit-card")
|
||||
fi
|
||||
|
||||
if printf '%s' "$CONTENT" | grep -Eiq -- "$SECRET_REGEX" \
|
||||
|| printf '%s' "$CONTENT" | grep -Eiq -- "$PRIVATE_KEY_REGEX" \
|
||||
|| printf '%s' "$CONTENT" | grep -Eiq -- "$DB_CONN_REGEX" \
|
||||
|| printf '%s' "$CONTENT" | grep -Eiq -- "$AWS_KEY_REGEX"; then
|
||||
STATUS="blocked"
|
||||
ACTION="block"
|
||||
RISK_LEVEL="high"
|
||||
MATCHED_RULES+=("secret-in-input")
|
||||
fi
|
||||
|
||||
if [[ "$STATUS" != "blocked" ]]; then
|
||||
for pattern in "${APPROVAL_PATTERNS[@]}"; do
|
||||
if match_either "$pattern"; then
|
||||
STATUS="requires_approval"
|
||||
ACTION="require_approval"
|
||||
RISK_LEVEL="high"
|
||||
MATCHED_RULES+=("unsafe-action:$pattern")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ "$STATUS" != "blocked" ]]; then
|
||||
for pattern in "${ALERT_PATTERNS[@]}"; do
|
||||
if match_either "$pattern"; then
|
||||
[[ "$RISK_LEVEL" == "low" ]] && RISK_LEVEL="medium"
|
||||
ACTION="alert"
|
||||
MATCHED_RULES+=("suspicious:$pattern")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
SAFE_CONTENT="$CONTENT"
|
||||
# Policy-driven PII masking (source of truth: pii-rules.yaml). Built-in sed
|
||||
# masking below remains as defense-in-depth if the policy file is unavailable.
|
||||
if [[ -f "$SECURITY_DIR/pii-rules.yaml" ]] && command -v python3 >/dev/null 2>&1; then
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | python3 "$SCRIPT_DIR/pii-mask.py" "$SECURITY_DIR/pii-rules.yaml")"
|
||||
fi
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$EMAIL_REGEX/***MASKED_EMAIL***/g")"
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$PHONE_REGEX/***MASKED_PHONE***/g")"
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$PERSONAL_ID_REGEX/***MASKED_ID***/g")"
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$SECRET_REGEX/[REDACTED_SECRET]/Ig")"
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$PRIVATE_KEY_REGEX/[REDACTED_PRIVATE_KEY]/Ig")"
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s#$DB_CONN_REGEX#[REDACTED_CONNSTRING]://#Ig")"
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$AWS_KEY_REGEX/[REDACTED_AWS_KEY]/Ig")"
|
||||
|
||||
if [[ "$MODE" == "output" ]]; then
|
||||
if printf '%s' "$CONTENT" | grep -Eiq -- "$SECRET_REGEX" \
|
||||
|| printf '%s' "$CONTENT" | grep -Eiq -- "$PRIVATE_KEY_REGEX" \
|
||||
|| printf '%s' "$CONTENT" | grep -Eiq -- "$DB_CONN_REGEX" \
|
||||
|| printf '%s' "$CONTENT" | grep -Eiq -- "$AWS_KEY_REGEX"; then
|
||||
# output-policy.yaml level4_gate.fail_on: unredacted_secret -> fail closed.
|
||||
STATUS="blocked"
|
||||
ACTION="block"
|
||||
RISK_LEVEL="high"
|
||||
MATCHED_RULES+=("secret-in-output")
|
||||
fi
|
||||
if printf '%s' "$lower_content" | grep -Eq -- "(maybe|might be incorrect|i am not sure|uncertain)"; then
|
||||
ACTION="flag"
|
||||
[[ "$RISK_LEVEL" == "low" ]] && RISK_LEVEL="medium"
|
||||
MATCHED_RULES+=("hallucination-risk-language")
|
||||
fi
|
||||
fi
|
||||
|
||||
INPUT_HASH="$(printf '%s' "$CONTENT" | hash_text)"
|
||||
OUTPUT_HASH="$(printf '%s' "$SAFE_CONTENT" | hash_text)"
|
||||
RULES_JSON="$(printf '%s\n' "${MATCHED_RULES[@]:-}" | python3 -c 'import json,sys; print(json.dumps([x for x in sys.stdin.read().splitlines() if x]))')"
|
||||
|
||||
TRACE_FILE="$TRACE_DIR/security-$TRACE_ID.json"
|
||||
cat > "$TRACE_FILE" <<EOF
|
||||
{
|
||||
"trace_id": "$TRACE_ID",
|
||||
"timestamp": "$TIMESTAMP",
|
||||
"harness": "H4-security",
|
||||
"mode": "$MODE",
|
||||
"status": "$STATUS",
|
||||
"action": "$ACTION",
|
||||
"risk_level": "$RISK_LEVEL",
|
||||
"matched_rules": $RULES_JSON,
|
||||
"input_hash": "$INPUT_HASH",
|
||||
"output_hash": "$OUTPUT_HASH"
|
||||
}
|
||||
EOF
|
||||
|
||||
printf '{"timestamp":"%s","trace_id":"%s","harness":"H4-security","mode":"%s","status":"%s","action":"%s","risk_level":"%s","input_hash":"%s","output_hash":"%s"}\n' \
|
||||
"$TIMESTAMP" "$TRACE_ID" "$MODE" "$STATUS" "$ACTION" "$RISK_LEVEL" "$INPUT_HASH" "$OUTPUT_HASH" >> "$AUDIT_DIR/security.jsonl"
|
||||
|
||||
if [[ "$STATUS" == "blocked" ]]; then
|
||||
: > "$OUTPUT_FILE"
|
||||
echo "SECURITY_BLOCKED trace_id=$TRACE_ID risk=$RISK_LEVEL rules=$RULES_JSON" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
printf '%s\n' "$SAFE_CONTENT" > "$OUTPUT_FILE"
|
||||
STATUS_UPPER="$(printf '%s' "$STATUS" | tr '[:lower:]' '[:upper:]')"
|
||||
echo "SECURITY_${STATUS_UPPER} trace_id=$TRACE_ID risk=$RISK_LEVEL action=$ACTION output=$OUTPUT_FILE"
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
# Parse command line arguments
|
||||
JSON_MODE=false
|
||||
ARGS=()
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--json)
|
||||
JSON_MODE=true
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--json]"
|
||||
echo " --json Output results in JSON format"
|
||||
echo " --help Show this help message"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
ARGS+=("$arg")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Get script directory and load common functions
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
# Get all paths and variables from common functions
|
||||
eval $(get_feature_paths)
|
||||
|
||||
# Check if we're on a proper feature branch (only for git repos)
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
|
||||
# Ensure the feature directory exists
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
# Copy plan template if it exists
|
||||
TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md"
|
||||
if [[ -f "$TEMPLATE" ]]; then
|
||||
cp "$TEMPLATE" "$IMPL_PLAN"
|
||||
echo "Copied plan template to $IMPL_PLAN"
|
||||
else
|
||||
echo "Warning: Plan template not found at $TEMPLATE"
|
||||
# Create a basic plan file if template doesn't exist
|
||||
touch "$IMPL_PLAN"
|
||||
fi
|
||||
|
||||
# Output results
|
||||
if $JSON_MODE; then
|
||||
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \
|
||||
"$FEATURE_SPEC" "$IMPL_PLAN" "$FEATURE_DIR" "$CURRENT_BRANCH" "$HAS_GIT"
|
||||
else
|
||||
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
||||
echo "IMPL_PLAN: $IMPL_PLAN"
|
||||
echo "SPECS_DIR: $FEATURE_DIR"
|
||||
echo "BRANCH: $CURRENT_BRANCH"
|
||||
echo "HAS_GIT: $HAS_GIT"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN Level 5 signed central policy bundle.
|
||||
# Usage:
|
||||
# sign-policy-bundle.sh sign
|
||||
# sign-policy-bundle.sh verify
|
||||
|
||||
MODE="${1:-}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
GOV_DIR="$PROJECT_ROOT/.specify/level5/central-governance"
|
||||
BUNDLE="$GOV_DIR/policy-bundle.yaml"
|
||||
MANIFEST="$GOV_DIR/policy-manifest.json"
|
||||
PRIVATE_KEY="$GOV_DIR/policy-private.pem"
|
||||
PUBLIC_KEY="$GOV_DIR/policy-public.pem"
|
||||
SIGNATURE="$GOV_DIR/policy-manifest.sig"
|
||||
mkdir -p "$GOV_DIR"
|
||||
|
||||
if [[ "$MODE" != "sign" && "$MODE" != "verify" ]]; then
|
||||
echo "Usage: sign-policy-bundle.sh sign|verify" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
if ! command -v openssl >/dev/null 2>&1; then
|
||||
echo "POLICY_SIGNING_UNAVAILABLE openssl not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
generate_manifest() {
|
||||
python3 - "$PROJECT_ROOT" "$BUNDLE" "$MANIFEST" <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
bundle = pathlib.Path(sys.argv[2])
|
||||
manifest = pathlib.Path(sys.argv[3])
|
||||
text = bundle.read_text(encoding="utf-8")
|
||||
paths = re.findall(r"^\s*path:\s*(.+?)\s*$", text, flags=re.MULTILINE)
|
||||
files = []
|
||||
for raw in paths:
|
||||
rel = raw.strip().strip('"')
|
||||
path = root / rel
|
||||
if not path.exists():
|
||||
raise SystemExit(f"missing policy file: {rel}")
|
||||
data = path.read_bytes()
|
||||
files.append({
|
||||
"path": rel,
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
"bytes": len(data),
|
||||
})
|
||||
payload = {
|
||||
"bundle_id": "casan-okr-harness-policy",
|
||||
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"files": files,
|
||||
}
|
||||
manifest.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(f"POLICY_MANIFEST_GENERATED files={len(files)} manifest={manifest}")
|
||||
PY
|
||||
}
|
||||
|
||||
if [[ "$MODE" == "sign" ]]; then
|
||||
generate_manifest
|
||||
if [[ ! -f "$PRIVATE_KEY" ]]; then
|
||||
openssl genrsa -out "$PRIVATE_KEY" 2048 >/dev/null 2>&1
|
||||
openssl rsa -in "$PRIVATE_KEY" -pubout -out "$PUBLIC_KEY" >/dev/null 2>&1
|
||||
fi
|
||||
openssl dgst -sha256 -sign "$PRIVATE_KEY" -out "$SIGNATURE" "$MANIFEST"
|
||||
echo "POLICY_BUNDLE_SIGNED manifest=$MANIFEST signature=$SIGNATURE public_key=$PUBLIC_KEY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python3 - "$PROJECT_ROOT" "$MANIFEST" <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
manifest = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8"))
|
||||
for item in manifest["files"]:
|
||||
data = (root / item["path"]).read_bytes()
|
||||
actual = hashlib.sha256(data).hexdigest()
|
||||
if actual != item["sha256"]:
|
||||
raise SystemExit(f"POLICY_HASH_MISMATCH path={item['path']} expected={item['sha256']} actual={actual}")
|
||||
print(f"POLICY_HASHES_VALID files={len(manifest['files'])}")
|
||||
PY
|
||||
openssl dgst -sha256 -verify "$PUBLIC_KEY" -signature "$SIGNATURE" "$MANIFEST" >/dev/null
|
||||
echo "POLICY_SIGNATURE_VALID manifest=$MANIFEST"
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared tamper-evident appender for the central tool-call audit log
|
||||
# (.specify/logs/audit/tool-calls.jsonl).
|
||||
#
|
||||
# Every record is chained: record_hash = SHA-256(previous_record_hash | core),
|
||||
# where core is the canonical (sorted-key) JSON of the record minus record_hash.
|
||||
# After each append the chain head is signed with the audit RSA key, so a
|
||||
# re-forged chain (recomputed hashes) cannot produce a valid head signature
|
||||
# without the private key. Used by both agent-metrics.sh and tool-registry-gate.sh
|
||||
# so the combined audit is tamper-evident regardless of which writer appended.
|
||||
#
|
||||
# Production note: the private key must live off-repo (KMS/HSM); it is local
|
||||
# here only for self-contained demonstration.
|
||||
|
||||
append_tool_audit() {
|
||||
local record_json="$1"
|
||||
local project_root="$2"
|
||||
local audit_dir="$project_root/.specify/logs/audit"
|
||||
local log="$audit_dir/tool-calls.jsonl"
|
||||
mkdir -p "$audit_dir"
|
||||
|
||||
local head
|
||||
head="$(python3 - "$log" "$record_json" <<'PY'
|
||||
import hashlib, json, sys
|
||||
log, rec_json = sys.argv[1], sys.argv[2]
|
||||
rec = json.loads(rec_json)
|
||||
prev = ""
|
||||
try:
|
||||
with open(log, encoding="utf-8") as f:
|
||||
lines = [l for l in f if l.strip()]
|
||||
if lines:
|
||||
prev = json.loads(lines[-1]).get("record_hash", "")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
rec.pop("record_hash", None)
|
||||
rec["previous_record_hash"] = prev
|
||||
core = json.dumps(rec, sort_keys=True, separators=(",", ":"))
|
||||
rec["record_hash"] = hashlib.sha256((prev + "|" + core).encode()).hexdigest()
|
||||
with open(log, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
sys.stdout.write(rec["record_hash"])
|
||||
PY
|
||||
)"
|
||||
|
||||
command -v openssl >/dev/null 2>&1 || return 0
|
||||
# Private signing key lives OFF-REPO (default ~/.casan/audit-keys); only the
|
||||
# public key is committed, for verification. Production: replace with KMS/HSM.
|
||||
local pub_dir="$project_root/.specify/level5/central-governance"
|
||||
local priv_dir="${CASAN_AUDIT_KEY_DIR:-$HOME/.casan/audit-keys}"
|
||||
local priv="$priv_dir/audit-private.pem" pub="$pub_dir/audit-public.pem"
|
||||
mkdir -p "$pub_dir" "$priv_dir"
|
||||
if [[ ! -f "$priv" ]]; then
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$priv" 2>/dev/null
|
||||
chmod 600 "$priv"
|
||||
openssl rsa -in "$priv" -pubout -out "$pub" 2>/dev/null
|
||||
fi
|
||||
printf '%s' "$head" > "$audit_dir/tool-calls-head.txt"
|
||||
openssl dgst -sha256 -sign "$priv" -out "$audit_dir/tool-calls-head.sig" "$audit_dir/tool-calls-head.txt" 2>/dev/null || true
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
# CASAN H4 tool-execution guard.
|
||||
# Runs a command under a hard wall-clock timeout so a runaway/hung tool call
|
||||
# cannot block the pipeline indefinitely. Portable (uses `timeout` if present,
|
||||
# else a perl alarm) — macOS has no coreutils `timeout` by default.
|
||||
#
|
||||
# Scope (honest): this enforces a TIMEOUT only. True kernel sandboxing
|
||||
# (namespaces/seccomp/network isolation) requires running the tool inside a
|
||||
# container and is documented as a production requirement — it is NOT emulated
|
||||
# here. Do not present this as full sandboxing.
|
||||
#
|
||||
# Usage: tool-exec.sh <timeout-seconds> -- <command...>
|
||||
# Exit: command's exit code, or 124 on timeout.
|
||||
|
||||
TIMEOUT="${1:-${CASAN_TOOL_TIMEOUT_SECONDS:-30}}"
|
||||
shift || true
|
||||
if [[ "${1:-}" == "--" ]]; then shift; fi
|
||||
if [[ "$#" -eq 0 ]]; then
|
||||
echo "Usage: tool-exec.sh <timeout-seconds> -- <command...>" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout "$TIMEOUT" "$@"
|
||||
rc=$?
|
||||
elif command -v perl >/dev/null 2>&1; then
|
||||
perl -e 'my $t=shift; $SIG{ALRM}=sub{exit 124}; alarm($t); exec @ARGV or exit 127;' "$TIMEOUT" "$@"
|
||||
rc=$?
|
||||
else
|
||||
echo "TOOL_EXEC_NO_TIMEOUT_BACKEND" >&2
|
||||
"$@"
|
||||
rc=$?
|
||||
fi
|
||||
|
||||
if [[ "$rc" -eq 124 || "$rc" -eq 142 ]]; then
|
||||
echo "TOOL_EXEC_TIMEOUT after ${TIMEOUT}s" >&2
|
||||
exit 124
|
||||
fi
|
||||
exit "$rc"
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN Level 5 tool registry gate.
|
||||
# Usage:
|
||||
# tool-registry-gate.sh <tool-id>
|
||||
|
||||
TOOL_ID="${1:-}"
|
||||
if [[ -z "$TOOL_ID" ]]; then
|
||||
echo "Usage: tool-registry-gate.sh <tool-id>" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
REGISTRY="$PROJECT_ROOT/.specify/level5/tool-registry.yaml"
|
||||
LOG_DIR="$PROJECT_ROOT/.specify/logs/level5"
|
||||
AUDIT_DIR="$PROJECT_ROOT/.specify/logs/audit"
|
||||
mkdir -p "$LOG_DIR" "$AUDIT_DIR"
|
||||
TRACE_ID="$(uuidgen 2>/dev/null | tr '[:upper:]' '[:lower:]' || printf 'tool-%s-%s' "$(date +%s)" "$$")"
|
||||
|
||||
# shellcheck source=tool-audit-lib.sh
|
||||
source "$SCRIPT_DIR/tool-audit-lib.sh"
|
||||
|
||||
AUDIT_TMP="$(mktemp)"
|
||||
trap 'rm -f "$AUDIT_TMP"' EXIT
|
||||
|
||||
DECISION_LINE="$(python3 - "$REGISTRY" "$TOOL_ID" "${CASAN_IDEMPOTENCY_KEY:-}" "$LOG_DIR/tool-registry.jsonl" "$TRACE_ID" "${CASAN_AGENT:-}" "$AUDIT_TMP" "${CASAN_RUN_ID:-adhoc-$$}" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
registry_path, tool_id, idempotency_key, log_path, trace_id, agent, audit_tmp, run_id = sys.argv[1:]
|
||||
text = open(registry_path, encoding="utf-8").read()
|
||||
blocks = re.split(r"\n\s*-\s+id:\s+", text)
|
||||
tools = {}
|
||||
for block in blocks[1:]:
|
||||
lines = block.splitlines()
|
||||
current_id = lines[0].strip()
|
||||
attrs = {"id": current_id, "has_rollback": "strategy:" in block}
|
||||
for line in lines[1:]:
|
||||
if ":" in line and not line.startswith(" "):
|
||||
key, value = line.split(":", 1)
|
||||
attrs[key.strip()] = value.strip().strip('"')
|
||||
tools[current_id] = attrs
|
||||
|
||||
tool = tools.get(tool_id)
|
||||
decision, reason = "approved", "registered"
|
||||
|
||||
if not tool:
|
||||
decision, reason = "denied", "unknown_tool"
|
||||
side_effect = idem_required = False
|
||||
owner = risk = ""
|
||||
allowed = ""
|
||||
else:
|
||||
side_effect = tool.get("side_effect", "false") == "true"
|
||||
idem_required = tool.get("idempotency_required", "false") == "true"
|
||||
owner = tool.get("owner", "")
|
||||
risk = tool.get("risk_level", "")
|
||||
allowed = tool.get("allowed_agents", "")
|
||||
allowed_list = [a.strip() for a in allowed.split(",") if a.strip()]
|
||||
|
||||
# 1. Per-agent least-privilege: restricted tools require an authorized caller.
|
||||
if allowed_list:
|
||||
if not agent:
|
||||
decision, reason = "denied", "missing_agent_identity"
|
||||
elif agent not in allowed_list:
|
||||
decision, reason = "denied", "unauthorized_agent"
|
||||
# 2. Side-effecting + idempotency-required tools must carry an idempotency key.
|
||||
if decision == "approved" and side_effect and idem_required and not idempotency_key:
|
||||
decision, reason = "denied", "missing_idempotency_key"
|
||||
# 3. Every side-effecting tool must declare a rollback strategy.
|
||||
if decision == "approved" and side_effect and not tool.get("has_rollback"):
|
||||
decision, reason = "denied", "missing_rollback_strategy"
|
||||
# 4. Runtime rate limit: count prior APPROVED calls for this tool in this run.
|
||||
if decision == "approved" and tool.get("rate_limit_per_run", "").isdigit():
|
||||
limit = int(tool["rate_limit_per_run"])
|
||||
prior = 0
|
||||
if os.path.exists(log_path):
|
||||
for line in open(log_path, encoding="utf-8"):
|
||||
try:
|
||||
r = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if (r.get("tool_id") == tool_id and r.get("run_id") == run_id
|
||||
and r.get("decision") == "approved"):
|
||||
prior += 1
|
||||
if prior >= limit:
|
||||
decision, reason = "denied", f"rate_limit_exceeded(limit={limit})"
|
||||
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
record = {
|
||||
"timestamp": ts, "trace_id": trace_id, "harness": "L5-tool-registry",
|
||||
"tool_id": tool_id, "agent": agent, "run_id": run_id, "owner": owner, "risk_level": risk,
|
||||
"side_effect": side_effect, "idempotency_required": idem_required,
|
||||
"idempotency_key_present": bool(idempotency_key),
|
||||
"decision": decision, "reason": reason,
|
||||
}
|
||||
with open(log_path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record) + "\n")
|
||||
|
||||
# Tamper-evident central audit record (appended + signed by the bash caller).
|
||||
with open(audit_tmp, "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps({
|
||||
"timestamp": ts, "trace_id": trace_id, "tool": tool_id, "agent": agent,
|
||||
"idempotency_key": idempotency_key, "decision": decision, "reason": reason,
|
||||
"risk_level": risk, "owner": owner,
|
||||
}))
|
||||
|
||||
print(f"{decision} {reason}")
|
||||
PY
|
||||
)"
|
||||
|
||||
DECISION="${DECISION_LINE%% *}"
|
||||
REASON="${DECISION_LINE#* }"
|
||||
|
||||
append_tool_audit "$(cat "$AUDIT_TMP")" "$PROJECT_ROOT"
|
||||
|
||||
if [[ "$DECISION" == "approved" ]]; then
|
||||
echo "TOOL_APPROVED tool=$TOOL_ID reason=$REASON"
|
||||
else
|
||||
echo "TOOL_DENIED tool=$TOOL_ID reason=$REASON" >&2
|
||||
exit 2
|
||||
fi
|
||||
@@ -0,0 +1,829 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Update agent context files with information from plan.md
|
||||
#
|
||||
# This script maintains AI agent context files by parsing feature specifications
|
||||
# and updating agent-specific configuration files with project information.
|
||||
#
|
||||
# MAIN FUNCTIONS:
|
||||
# 1. Environment Validation
|
||||
# - Verifies git repository structure and branch information
|
||||
# - Checks for required plan.md files and templates
|
||||
# - Validates file permissions and accessibility
|
||||
#
|
||||
# 2. Plan Data Extraction
|
||||
# - Parses plan.md files to extract project metadata
|
||||
# - Identifies language/version, frameworks, databases, and project types
|
||||
# - Handles missing or incomplete specification data gracefully
|
||||
#
|
||||
# 3. Agent File Management
|
||||
# - Creates new agent context files from templates when needed
|
||||
# - Updates existing agent files with new project information
|
||||
# - Preserves manual additions and custom configurations
|
||||
# - Supports multiple AI agent formats and directory structures
|
||||
#
|
||||
# 4. Content Generation
|
||||
# - Generates language-specific build/test commands
|
||||
# - Creates appropriate project directory structures
|
||||
# - Updates technology stacks and recent changes sections
|
||||
# - Maintains consistent formatting and timestamps
|
||||
#
|
||||
# 5. Multi-Agent Support
|
||||
# - Handles agent-specific file paths and naming conventions
|
||||
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Kiro CLI, or Antigravity
|
||||
# - Can update single agents or all existing agent files
|
||||
# - Creates default Claude file if no agent files exist
|
||||
#
|
||||
# Usage: ./update-agent-context.sh [agent_type]
|
||||
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli
|
||||
# Leave empty to update all existing agent files
|
||||
|
||||
set -e
|
||||
|
||||
# Enable strict error handling
|
||||
set -u
|
||||
set -o pipefail
|
||||
|
||||
#==============================================================================
|
||||
# Configuration and Global Variables
|
||||
#==============================================================================
|
||||
|
||||
# Get script directory and load common functions
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
# Get all paths and variables from common functions
|
||||
eval $(get_feature_paths)
|
||||
|
||||
NEW_PLAN="$IMPL_PLAN" # Alias for compatibility with existing code
|
||||
AGENT_TYPE="${1:-}"
|
||||
|
||||
# Agent-specific file paths
|
||||
CLAUDE_FILE="$REPO_ROOT/CLAUDE.md"
|
||||
GEMINI_FILE="$REPO_ROOT/GEMINI.md"
|
||||
COPILOT_FILE="$REPO_ROOT/.github/agents/copilot-instructions.md"
|
||||
CURSOR_FILE="$REPO_ROOT/.cursor/rules/specify-rules.mdc"
|
||||
QWEN_FILE="$REPO_ROOT/QWEN.md"
|
||||
AGENTS_FILE="$REPO_ROOT/AGENTS.md"
|
||||
WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md"
|
||||
KILOCODE_FILE="$REPO_ROOT/.kilocode/rules/specify-rules.md"
|
||||
AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md"
|
||||
ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md"
|
||||
CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md"
|
||||
QODER_FILE="$REPO_ROOT/QODER.md"
|
||||
AMP_FILE="$REPO_ROOT/AGENTS.md"
|
||||
SHAI_FILE="$REPO_ROOT/SHAI.md"
|
||||
KIRO_FILE="$REPO_ROOT/AGENTS.md"
|
||||
AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md"
|
||||
BOB_FILE="$REPO_ROOT/AGENTS.md"
|
||||
|
||||
# Template file
|
||||
TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md"
|
||||
|
||||
# Global variables for parsed plan data
|
||||
NEW_LANG=""
|
||||
NEW_FRAMEWORK=""
|
||||
NEW_DB=""
|
||||
NEW_PROJECT_TYPE=""
|
||||
|
||||
#==============================================================================
|
||||
# Utility Functions
|
||||
#==============================================================================
|
||||
|
||||
log_info() {
|
||||
echo "INFO: $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo "✓ $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo "ERROR: $1" >&2
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo "WARNING: $1" >&2
|
||||
}
|
||||
|
||||
# Cleanup function for temporary files
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
rm -f /tmp/agent_update_*_$$
|
||||
rm -f /tmp/manual_additions_$$
|
||||
exit $exit_code
|
||||
}
|
||||
|
||||
# Set up cleanup trap
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
#==============================================================================
|
||||
# Validation Functions
|
||||
#==============================================================================
|
||||
|
||||
validate_environment() {
|
||||
# Check if we have a current branch/feature (git or non-git)
|
||||
if [[ -z "$CURRENT_BRANCH" ]]; then
|
||||
log_error "Unable to determine current feature"
|
||||
if [[ "$HAS_GIT" == "true" ]]; then
|
||||
log_info "Make sure you're on a feature branch"
|
||||
else
|
||||
log_info "Set SPECIFY_FEATURE environment variable or create a feature first"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if plan.md exists
|
||||
if [[ ! -f "$NEW_PLAN" ]]; then
|
||||
log_error "No plan.md found at $NEW_PLAN"
|
||||
log_info "Make sure you're working on a feature with a corresponding spec directory"
|
||||
if [[ "$HAS_GIT" != "true" ]]; then
|
||||
log_info "Use: export SPECIFY_FEATURE=your-feature-name or create a new feature first"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if template exists (needed for new files)
|
||||
if [[ ! -f "$TEMPLATE_FILE" ]]; then
|
||||
log_warning "Template file not found at $TEMPLATE_FILE"
|
||||
log_warning "Creating new agent files will fail"
|
||||
fi
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
# Plan Parsing Functions
|
||||
#==============================================================================
|
||||
|
||||
extract_plan_field() {
|
||||
local field_pattern="$1"
|
||||
local plan_file="$2"
|
||||
|
||||
grep "^\*\*${field_pattern}\*\*: " "$plan_file" 2>/dev/null | \
|
||||
head -1 | \
|
||||
sed "s|^\*\*${field_pattern}\*\*: ||" | \
|
||||
sed 's/^[ \t]*//;s/[ \t]*$//' | \
|
||||
grep -v "NEEDS CLARIFICATION" | \
|
||||
grep -v "^N/A$" || echo ""
|
||||
}
|
||||
|
||||
parse_plan_data() {
|
||||
local plan_file="$1"
|
||||
|
||||
if [[ ! -f "$plan_file" ]]; then
|
||||
log_error "Plan file not found: $plan_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -r "$plan_file" ]]; then
|
||||
log_error "Plan file is not readable: $plan_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Parsing plan data from $plan_file"
|
||||
|
||||
NEW_LANG=$(extract_plan_field "Language/Version" "$plan_file")
|
||||
NEW_FRAMEWORK=$(extract_plan_field "Primary Dependencies" "$plan_file")
|
||||
NEW_DB=$(extract_plan_field "Storage" "$plan_file")
|
||||
NEW_PROJECT_TYPE=$(extract_plan_field "Project Type" "$plan_file")
|
||||
|
||||
# Log what we found
|
||||
if [[ -n "$NEW_LANG" ]]; then
|
||||
log_info "Found language: $NEW_LANG"
|
||||
else
|
||||
log_warning "No language information found in plan"
|
||||
fi
|
||||
|
||||
if [[ -n "$NEW_FRAMEWORK" ]]; then
|
||||
log_info "Found framework: $NEW_FRAMEWORK"
|
||||
fi
|
||||
|
||||
if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]]; then
|
||||
log_info "Found database: $NEW_DB"
|
||||
fi
|
||||
|
||||
if [[ -n "$NEW_PROJECT_TYPE" ]]; then
|
||||
log_info "Found project type: $NEW_PROJECT_TYPE"
|
||||
fi
|
||||
}
|
||||
|
||||
format_technology_stack() {
|
||||
local lang="$1"
|
||||
local framework="$2"
|
||||
local parts=()
|
||||
|
||||
# Add non-empty parts
|
||||
[[ -n "$lang" && "$lang" != "NEEDS CLARIFICATION" ]] && parts+=("$lang")
|
||||
[[ -n "$framework" && "$framework" != "NEEDS CLARIFICATION" && "$framework" != "N/A" ]] && parts+=("$framework")
|
||||
|
||||
# Join with proper formatting
|
||||
if [[ ${#parts[@]} -eq 0 ]]; then
|
||||
echo ""
|
||||
elif [[ ${#parts[@]} -eq 1 ]]; then
|
||||
echo "${parts[0]}"
|
||||
else
|
||||
# Join multiple parts with " + "
|
||||
local result="${parts[0]}"
|
||||
for ((i=1; i<${#parts[@]}; i++)); do
|
||||
result="$result + ${parts[i]}"
|
||||
done
|
||||
echo "$result"
|
||||
fi
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
# Template and Content Generation Functions
|
||||
#==============================================================================
|
||||
|
||||
get_project_structure() {
|
||||
local project_type="$1"
|
||||
|
||||
if [[ "$project_type" == *"web"* ]]; then
|
||||
echo "backend/\\nfrontend/\\ntests/"
|
||||
else
|
||||
echo "src/\\ntests/"
|
||||
fi
|
||||
}
|
||||
|
||||
get_commands_for_language() {
|
||||
local lang="$1"
|
||||
|
||||
case "$lang" in
|
||||
*"Python"*)
|
||||
echo "cd src && pytest && ruff check ."
|
||||
;;
|
||||
*"Rust"*)
|
||||
echo "cargo test && cargo clippy"
|
||||
;;
|
||||
*"JavaScript"*|*"TypeScript"*)
|
||||
echo "npm test \\&\\& npm run lint"
|
||||
;;
|
||||
*)
|
||||
echo "# Add commands for $lang"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
get_language_conventions() {
|
||||
local lang="$1"
|
||||
echo "$lang: Follow standard conventions"
|
||||
}
|
||||
|
||||
create_new_agent_file() {
|
||||
local target_file="$1"
|
||||
local temp_file="$2"
|
||||
local project_name="$3"
|
||||
local current_date="$4"
|
||||
|
||||
if [[ ! -f "$TEMPLATE_FILE" ]]; then
|
||||
log_error "Template not found at $TEMPLATE_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -r "$TEMPLATE_FILE" ]]; then
|
||||
log_error "Template file is not readable: $TEMPLATE_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Creating new agent context file from template..."
|
||||
|
||||
if ! cp "$TEMPLATE_FILE" "$temp_file"; then
|
||||
log_error "Failed to copy template file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Replace template placeholders
|
||||
local project_structure
|
||||
project_structure=$(get_project_structure "$NEW_PROJECT_TYPE")
|
||||
|
||||
local commands
|
||||
commands=$(get_commands_for_language "$NEW_LANG")
|
||||
|
||||
local language_conventions
|
||||
language_conventions=$(get_language_conventions "$NEW_LANG")
|
||||
|
||||
# Perform substitutions with error checking using safer approach
|
||||
# Escape special characters for sed by using a different delimiter or escaping
|
||||
local escaped_lang=$(printf '%s\n' "$NEW_LANG" | sed 's/[\[\.*^$()+{}|]/\\&/g')
|
||||
local escaped_framework=$(printf '%s\n' "$NEW_FRAMEWORK" | sed 's/[\[\.*^$()+{}|]/\\&/g')
|
||||
local escaped_branch=$(printf '%s\n' "$CURRENT_BRANCH" | sed 's/[\[\.*^$()+{}|]/\\&/g')
|
||||
|
||||
# Build technology stack and recent change strings conditionally
|
||||
local tech_stack
|
||||
if [[ -n "$escaped_lang" && -n "$escaped_framework" ]]; then
|
||||
tech_stack="- $escaped_lang + $escaped_framework ($escaped_branch)"
|
||||
elif [[ -n "$escaped_lang" ]]; then
|
||||
tech_stack="- $escaped_lang ($escaped_branch)"
|
||||
elif [[ -n "$escaped_framework" ]]; then
|
||||
tech_stack="- $escaped_framework ($escaped_branch)"
|
||||
else
|
||||
tech_stack="- ($escaped_branch)"
|
||||
fi
|
||||
|
||||
local recent_change
|
||||
if [[ -n "$escaped_lang" && -n "$escaped_framework" ]]; then
|
||||
recent_change="- $escaped_branch: Added $escaped_lang + $escaped_framework"
|
||||
elif [[ -n "$escaped_lang" ]]; then
|
||||
recent_change="- $escaped_branch: Added $escaped_lang"
|
||||
elif [[ -n "$escaped_framework" ]]; then
|
||||
recent_change="- $escaped_branch: Added $escaped_framework"
|
||||
else
|
||||
recent_change="- $escaped_branch: Added"
|
||||
fi
|
||||
|
||||
local substitutions=(
|
||||
"s|\[PROJECT NAME\]|$project_name|"
|
||||
"s|\[DATE\]|$current_date|"
|
||||
"s|\[EXTRACTED FROM ALL PLAN.MD FILES\]|$tech_stack|"
|
||||
"s|\[ACTUAL STRUCTURE FROM PLANS\]|$project_structure|g"
|
||||
"s|\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]|$commands|"
|
||||
"s|\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]|$language_conventions|"
|
||||
"s|\[LAST 3 FEATURES AND WHAT THEY ADDED\]|$recent_change|"
|
||||
)
|
||||
|
||||
for substitution in "${substitutions[@]}"; do
|
||||
if ! sed -i.bak -e "$substitution" "$temp_file"; then
|
||||
log_error "Failed to perform substitution: $substitution"
|
||||
rm -f "$temp_file" "$temp_file.bak"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Convert \n sequences to actual newlines
|
||||
newline=$(printf '\n')
|
||||
sed -i.bak2 "s/\\\\n/${newline}/g" "$temp_file"
|
||||
|
||||
# Clean up backup files
|
||||
rm -f "$temp_file.bak" "$temp_file.bak2"
|
||||
|
||||
# Prepend Cursor frontmatter for .mdc files so rules are auto-included
|
||||
if [[ "$target_file" == *.mdc ]]; then
|
||||
local frontmatter_file
|
||||
frontmatter_file=$(mktemp) || return 1
|
||||
printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file"
|
||||
cat "$temp_file" >> "$frontmatter_file"
|
||||
mv "$frontmatter_file" "$temp_file"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
update_existing_agent_file() {
|
||||
local target_file="$1"
|
||||
local current_date="$2"
|
||||
|
||||
log_info "Updating existing agent context file..."
|
||||
|
||||
# Use a single temporary file for atomic update
|
||||
local temp_file
|
||||
temp_file=$(mktemp) || {
|
||||
log_error "Failed to create temporary file"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Process the file in one pass
|
||||
local tech_stack=$(format_technology_stack "$NEW_LANG" "$NEW_FRAMEWORK")
|
||||
local new_tech_entries=()
|
||||
local new_change_entry=""
|
||||
|
||||
# Prepare new technology entries
|
||||
if [[ -n "$tech_stack" ]] && ! grep -q "$tech_stack" "$target_file"; then
|
||||
new_tech_entries+=("- $tech_stack ($CURRENT_BRANCH)")
|
||||
fi
|
||||
|
||||
if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]] && [[ "$NEW_DB" != "NEEDS CLARIFICATION" ]] && ! grep -q "$NEW_DB" "$target_file"; then
|
||||
new_tech_entries+=("- $NEW_DB ($CURRENT_BRANCH)")
|
||||
fi
|
||||
|
||||
# Prepare new change entry
|
||||
if [[ -n "$tech_stack" ]]; then
|
||||
new_change_entry="- $CURRENT_BRANCH: Added $tech_stack"
|
||||
elif [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]] && [[ "$NEW_DB" != "NEEDS CLARIFICATION" ]]; then
|
||||
new_change_entry="- $CURRENT_BRANCH: Added $NEW_DB"
|
||||
fi
|
||||
|
||||
# Check if sections exist in the file
|
||||
local has_active_technologies=0
|
||||
local has_recent_changes=0
|
||||
|
||||
if grep -q "^## Active Technologies" "$target_file" 2>/dev/null; then
|
||||
has_active_technologies=1
|
||||
fi
|
||||
|
||||
if grep -q "^## Recent Changes" "$target_file" 2>/dev/null; then
|
||||
has_recent_changes=1
|
||||
fi
|
||||
|
||||
# Process file line by line
|
||||
local in_tech_section=false
|
||||
local in_changes_section=false
|
||||
local tech_entries_added=false
|
||||
local changes_entries_added=false
|
||||
local existing_changes_count=0
|
||||
local file_ended=false
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
# Handle Active Technologies section
|
||||
if [[ "$line" == "## Active Technologies" ]]; then
|
||||
echo "$line" >> "$temp_file"
|
||||
in_tech_section=true
|
||||
continue
|
||||
elif [[ $in_tech_section == true ]] && [[ "$line" =~ ^##[[:space:]] ]]; then
|
||||
# Add new tech entries before closing the section
|
||||
if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then
|
||||
printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file"
|
||||
tech_entries_added=true
|
||||
fi
|
||||
echo "$line" >> "$temp_file"
|
||||
in_tech_section=false
|
||||
continue
|
||||
elif [[ $in_tech_section == true ]] && [[ -z "$line" ]]; then
|
||||
# Add new tech entries before empty line in tech section
|
||||
if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then
|
||||
printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file"
|
||||
tech_entries_added=true
|
||||
fi
|
||||
echo "$line" >> "$temp_file"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Handle Recent Changes section
|
||||
if [[ "$line" == "## Recent Changes" ]]; then
|
||||
echo "$line" >> "$temp_file"
|
||||
# Add new change entry right after the heading
|
||||
if [[ -n "$new_change_entry" ]]; then
|
||||
echo "$new_change_entry" >> "$temp_file"
|
||||
fi
|
||||
in_changes_section=true
|
||||
changes_entries_added=true
|
||||
continue
|
||||
elif [[ $in_changes_section == true ]] && [[ "$line" =~ ^##[[:space:]] ]]; then
|
||||
echo "$line" >> "$temp_file"
|
||||
in_changes_section=false
|
||||
continue
|
||||
elif [[ $in_changes_section == true ]] && [[ "$line" == "- "* ]]; then
|
||||
# Keep only first 2 existing changes
|
||||
if [[ $existing_changes_count -lt 2 ]]; then
|
||||
echo "$line" >> "$temp_file"
|
||||
((existing_changes_count++))
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
# Update timestamp
|
||||
if [[ "$line" =~ \*\*Last\ updated\*\*:.*[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]; then
|
||||
echo "$line" | sed "s/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/$current_date/" >> "$temp_file"
|
||||
else
|
||||
echo "$line" >> "$temp_file"
|
||||
fi
|
||||
done < "$target_file"
|
||||
|
||||
# Post-loop check: if we're still in the Active Technologies section and haven't added new entries
|
||||
if [[ $in_tech_section == true ]] && [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then
|
||||
printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file"
|
||||
tech_entries_added=true
|
||||
fi
|
||||
|
||||
# If sections don't exist, add them at the end of the file
|
||||
if [[ $has_active_technologies -eq 0 ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then
|
||||
echo "" >> "$temp_file"
|
||||
echo "## Active Technologies" >> "$temp_file"
|
||||
printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file"
|
||||
tech_entries_added=true
|
||||
fi
|
||||
|
||||
if [[ $has_recent_changes -eq 0 ]] && [[ -n "$new_change_entry" ]]; then
|
||||
echo "" >> "$temp_file"
|
||||
echo "## Recent Changes" >> "$temp_file"
|
||||
echo "$new_change_entry" >> "$temp_file"
|
||||
changes_entries_added=true
|
||||
fi
|
||||
|
||||
# Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion
|
||||
if [[ "$target_file" == *.mdc ]]; then
|
||||
if ! head -1 "$temp_file" | grep -q '^---'; then
|
||||
local frontmatter_file
|
||||
frontmatter_file=$(mktemp) || { rm -f "$temp_file"; return 1; }
|
||||
printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file"
|
||||
cat "$temp_file" >> "$frontmatter_file"
|
||||
mv "$frontmatter_file" "$temp_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Move temp file to target atomically
|
||||
if ! mv "$temp_file" "$target_file"; then
|
||||
log_error "Failed to update target file"
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
#==============================================================================
|
||||
# Main Agent File Update Function
|
||||
#==============================================================================
|
||||
|
||||
update_agent_file() {
|
||||
local target_file="$1"
|
||||
local agent_name="$2"
|
||||
|
||||
if [[ -z "$target_file" ]] || [[ -z "$agent_name" ]]; then
|
||||
log_error "update_agent_file requires target_file and agent_name parameters"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Updating $agent_name context file: $target_file"
|
||||
|
||||
local project_name
|
||||
project_name=$(basename "$REPO_ROOT")
|
||||
local current_date
|
||||
current_date=$(date +%Y-%m-%d)
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
local target_dir
|
||||
target_dir=$(dirname "$target_file")
|
||||
if [[ ! -d "$target_dir" ]]; then
|
||||
if ! mkdir -p "$target_dir"; then
|
||||
log_error "Failed to create directory: $target_dir"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -f "$target_file" ]]; then
|
||||
# Create new file from template
|
||||
local temp_file
|
||||
temp_file=$(mktemp) || {
|
||||
log_error "Failed to create temporary file"
|
||||
return 1
|
||||
}
|
||||
|
||||
if create_new_agent_file "$target_file" "$temp_file" "$project_name" "$current_date"; then
|
||||
if mv "$temp_file" "$target_file"; then
|
||||
log_success "Created new $agent_name context file"
|
||||
else
|
||||
log_error "Failed to move temporary file to $target_file"
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
log_error "Failed to create new agent file"
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
# Update existing file
|
||||
if [[ ! -r "$target_file" ]]; then
|
||||
log_error "Cannot read existing file: $target_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -w "$target_file" ]]; then
|
||||
log_error "Cannot write to existing file: $target_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if update_existing_agent_file "$target_file" "$current_date"; then
|
||||
log_success "Updated existing $agent_name context file"
|
||||
else
|
||||
log_error "Failed to update existing agent file"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
# Agent Selection and Processing
|
||||
#==============================================================================
|
||||
|
||||
update_specific_agent() {
|
||||
local agent_type="$1"
|
||||
|
||||
case "$agent_type" in
|
||||
claude)
|
||||
update_agent_file "$CLAUDE_FILE" "Claude Code"
|
||||
;;
|
||||
gemini)
|
||||
update_agent_file "$GEMINI_FILE" "Gemini CLI"
|
||||
;;
|
||||
copilot)
|
||||
update_agent_file "$COPILOT_FILE" "GitHub Copilot"
|
||||
;;
|
||||
cursor-agent)
|
||||
update_agent_file "$CURSOR_FILE" "Cursor IDE"
|
||||
;;
|
||||
qwen)
|
||||
update_agent_file "$QWEN_FILE" "Qwen Code"
|
||||
;;
|
||||
opencode)
|
||||
update_agent_file "$AGENTS_FILE" "opencode"
|
||||
;;
|
||||
codex)
|
||||
update_agent_file "$AGENTS_FILE" "Codex CLI"
|
||||
;;
|
||||
windsurf)
|
||||
update_agent_file "$WINDSURF_FILE" "Windsurf"
|
||||
;;
|
||||
kilocode)
|
||||
update_agent_file "$KILOCODE_FILE" "Kilo Code"
|
||||
;;
|
||||
auggie)
|
||||
update_agent_file "$AUGGIE_FILE" "Auggie CLI"
|
||||
;;
|
||||
roo)
|
||||
update_agent_file "$ROO_FILE" "Roo Code"
|
||||
;;
|
||||
codebuddy)
|
||||
update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI"
|
||||
;;
|
||||
qodercli)
|
||||
update_agent_file "$QODER_FILE" "Qoder CLI"
|
||||
;;
|
||||
amp)
|
||||
update_agent_file "$AMP_FILE" "Amp"
|
||||
;;
|
||||
shai)
|
||||
update_agent_file "$SHAI_FILE" "SHAI"
|
||||
;;
|
||||
kiro-cli)
|
||||
update_agent_file "$KIRO_FILE" "Kiro CLI"
|
||||
;;
|
||||
agy)
|
||||
update_agent_file "$AGY_FILE" "Antigravity"
|
||||
;;
|
||||
bob)
|
||||
update_agent_file "$BOB_FILE" "IBM Bob"
|
||||
;;
|
||||
generic)
|
||||
log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent."
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown agent type '$agent_type'"
|
||||
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|generic"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
update_all_existing_agents() {
|
||||
local found_agent=false
|
||||
|
||||
# Check each possible agent file and update if it exists
|
||||
if [[ -f "$CLAUDE_FILE" ]]; then
|
||||
update_agent_file "$CLAUDE_FILE" "Claude Code"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$GEMINI_FILE" ]]; then
|
||||
update_agent_file "$GEMINI_FILE" "Gemini CLI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$COPILOT_FILE" ]]; then
|
||||
update_agent_file "$COPILOT_FILE" "GitHub Copilot"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$CURSOR_FILE" ]]; then
|
||||
update_agent_file "$CURSOR_FILE" "Cursor IDE"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$QWEN_FILE" ]]; then
|
||||
update_agent_file "$QWEN_FILE" "Qwen Code"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$AGENTS_FILE" ]]; then
|
||||
update_agent_file "$AGENTS_FILE" "Codex/opencode"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$WINDSURF_FILE" ]]; then
|
||||
update_agent_file "$WINDSURF_FILE" "Windsurf"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$KILOCODE_FILE" ]]; then
|
||||
update_agent_file "$KILOCODE_FILE" "Kilo Code"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$AUGGIE_FILE" ]]; then
|
||||
update_agent_file "$AUGGIE_FILE" "Auggie CLI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$ROO_FILE" ]]; then
|
||||
update_agent_file "$ROO_FILE" "Roo Code"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$CODEBUDDY_FILE" ]]; then
|
||||
update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$SHAI_FILE" ]]; then
|
||||
update_agent_file "$SHAI_FILE" "SHAI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$QODER_FILE" ]]; then
|
||||
update_agent_file "$QODER_FILE" "Qoder CLI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$KIRO_FILE" ]]; then
|
||||
update_agent_file "$KIRO_FILE" "Kiro CLI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$AGY_FILE" ]]; then
|
||||
update_agent_file "$AGY_FILE" "Antigravity"
|
||||
found_agent=true
|
||||
fi
|
||||
if [[ -f "$BOB_FILE" ]]; then
|
||||
update_agent_file "$BOB_FILE" "IBM Bob"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
# If no agent files exist, create a default Claude file
|
||||
if [[ "$found_agent" == false ]]; then
|
||||
log_info "No existing agent files found, creating default Claude file..."
|
||||
update_agent_file "$CLAUDE_FILE" "Claude Code"
|
||||
fi
|
||||
}
|
||||
print_summary() {
|
||||
echo
|
||||
log_info "Summary of changes:"
|
||||
|
||||
if [[ -n "$NEW_LANG" ]]; then
|
||||
echo " - Added language: $NEW_LANG"
|
||||
fi
|
||||
|
||||
if [[ -n "$NEW_FRAMEWORK" ]]; then
|
||||
echo " - Added framework: $NEW_FRAMEWORK"
|
||||
fi
|
||||
|
||||
if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]]; then
|
||||
echo " - Added database: $NEW_DB"
|
||||
fi
|
||||
|
||||
echo
|
||||
|
||||
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli]"
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
# Main Execution
|
||||
#==============================================================================
|
||||
|
||||
main() {
|
||||
# Validate environment before proceeding
|
||||
validate_environment
|
||||
|
||||
log_info "=== Updating agent context files for feature $CURRENT_BRANCH ==="
|
||||
|
||||
# Parse the plan file to extract project information
|
||||
if ! parse_plan_data "$NEW_PLAN"; then
|
||||
log_error "Failed to parse plan data"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Process based on agent type argument
|
||||
local success=true
|
||||
|
||||
if [[ -z "$AGENT_TYPE" ]]; then
|
||||
# No specific agent provided - update all existing agent files
|
||||
log_info "No agent specified, updating all existing agent files..."
|
||||
if ! update_all_existing_agents; then
|
||||
success=false
|
||||
fi
|
||||
else
|
||||
# Specific agent provided - update only that agent
|
||||
log_info "Updating specific agent: $AGENT_TYPE"
|
||||
if ! update_specific_agent "$AGENT_TYPE"; then
|
||||
success=false
|
||||
fi
|
||||
fi
|
||||
|
||||
# Print summary
|
||||
print_summary
|
||||
|
||||
if [[ "$success" == true ]]; then
|
||||
log_success "Agent context update completed successfully"
|
||||
exit 0
|
||||
else
|
||||
log_error "Agent context update completed with errors"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Execute main function if script is run directly
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN H2 tool-input validation.
|
||||
# Validates a tool-call input JSON against a JSON-Schema-style spec
|
||||
# (required fields + property types + additionalProperties:false).
|
||||
# Stdlib-only — supports type, required, properties, additionalProperties,
|
||||
# enum. NOT a full JSON Schema engine (no $ref, no nested object recursion
|
||||
# beyond one level); scoped deliberately and labeled as such.
|
||||
#
|
||||
# Usage: validate-tool-input.sh <schema.json> <input.json>
|
||||
# Exit: 0 valid, 2 invalid, 64 usage error.
|
||||
|
||||
SCHEMA="${1:-}"
|
||||
INPUT="${2:-}"
|
||||
if [[ -z "$SCHEMA" || -z "$INPUT" || ! -f "$SCHEMA" || ! -f "$INPUT" ]]; then
|
||||
echo "Usage: validate-tool-input.sh <schema.json> <input.json>" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
python3 - "$SCHEMA" "$INPUT" <<'PY'
|
||||
import json, sys
|
||||
|
||||
schema = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
data = json.load(open(sys.argv[2], encoding="utf-8"))
|
||||
|
||||
TYPES = {
|
||||
"string": str, "integer": int, "number": (int, float),
|
||||
"boolean": bool, "object": dict, "array": list,
|
||||
}
|
||||
errors = []
|
||||
|
||||
if schema.get("type") == "object" and not isinstance(data, dict):
|
||||
errors.append("root: expected object")
|
||||
else:
|
||||
props = schema.get("properties", {})
|
||||
for field in schema.get("required", []):
|
||||
if field not in data:
|
||||
errors.append(f"missing required field: {field}")
|
||||
if schema.get("additionalProperties") is False:
|
||||
for key in data:
|
||||
if key not in props:
|
||||
errors.append(f"unexpected field: {key}")
|
||||
for key, spec in props.items():
|
||||
if key not in data:
|
||||
continue
|
||||
expected = spec.get("type")
|
||||
py = TYPES.get(expected)
|
||||
# bool is a subclass of int — guard so a boolean isn't accepted as integer
|
||||
if py and (not isinstance(data[key], py)
|
||||
or (expected in ("integer", "number") and isinstance(data[key], bool))):
|
||||
errors.append(f"field {key}: expected {expected}")
|
||||
if "enum" in spec and data[key] not in spec["enum"]:
|
||||
errors.append(f"field {key}: not in enum {spec['enum']}")
|
||||
|
||||
if errors:
|
||||
sys.stderr.write("TOOL_INPUT_INVALID " + "; ".join(errors) + "\n")
|
||||
raise SystemExit(2)
|
||||
print("TOOL_INPUT_VALID")
|
||||
PY
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Verify CASAN H5 append-only hash-chain audit log.
|
||||
# Usage:
|
||||
# verify-audit-chain.sh [audit-jsonl]
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
AUDIT_LOG="${1:-$PROJECT_ROOT/.specify/logs/audit/audit.jsonl}"
|
||||
|
||||
if [[ ! -f "$AUDIT_LOG" ]]; then
|
||||
echo "AUDIT_CHAIN_MISSING file=$AUDIT_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
COMPUTED_HEAD="$(python3 - "$AUDIT_LOG" <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
|
||||
path = sys.argv[1]
|
||||
previous = ""
|
||||
count = 0
|
||||
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line_no, line in enumerate(f, 1):
|
||||
if not line.strip():
|
||||
continue
|
||||
record = json.loads(line)
|
||||
expected_previous = record.get("previous_record_hash", "")
|
||||
if expected_previous != previous:
|
||||
raise SystemExit(
|
||||
f"AUDIT_CHAIN_BROKEN line={line_no} expected_previous={previous} actual_previous={expected_previous}"
|
||||
)
|
||||
|
||||
core = "|".join(
|
||||
[
|
||||
record.get("timestamp", ""),
|
||||
record.get("trace_id", ""),
|
||||
record.get("action", ""),
|
||||
record.get("actor", ""),
|
||||
record.get("risk_level", ""),
|
||||
record.get("decision", ""),
|
||||
record.get("approval_status", ""),
|
||||
record.get("approver", ""),
|
||||
record.get("input_hash", ""),
|
||||
record.get("output_hash", ""),
|
||||
expected_previous,
|
||||
]
|
||||
)
|
||||
expected_hash = hashlib.sha256(core.encode()).hexdigest()
|
||||
actual_hash = record.get("record_hash", "")
|
||||
if expected_hash != actual_hash:
|
||||
raise SystemExit(
|
||||
f"AUDIT_HASH_MISMATCH line={line_no} expected={expected_hash} actual={actual_hash}"
|
||||
)
|
||||
previous = actual_hash
|
||||
count += 1
|
||||
|
||||
# Emit count and head on stderr (human) and the head on stdout (captured).
|
||||
sys.stderr.write(f"AUDIT_CHAIN_INTEGRITY_OK records={count}\n")
|
||||
sys.stdout.write(previous)
|
||||
PY
|
||||
)"
|
||||
|
||||
# --- External anchor verification ---
|
||||
# Recomputing a forged chain yields a different head; the stored head signature
|
||||
# was produced with a private key the forger does not have, so it will not match.
|
||||
AUDIT_DIR="$(dirname "$AUDIT_LOG")"
|
||||
HEAD_FILE="$AUDIT_DIR/audit-head.txt"
|
||||
HEAD_SIG="$AUDIT_DIR/audit-head.sig"
|
||||
AUDIT_PUB="$PROJECT_ROOT/.specify/level5/central-governance/audit-public.pem"
|
||||
|
||||
if [[ -f "$HEAD_FILE" && -f "$HEAD_SIG" && -f "$AUDIT_PUB" ]] && command -v openssl >/dev/null 2>&1; then
|
||||
STORED_HEAD="$(cat "$HEAD_FILE")"
|
||||
if [[ "$STORED_HEAD" != "$COMPUTED_HEAD" ]]; then
|
||||
echo "AUDIT_HEAD_MISMATCH computed=$COMPUTED_HEAD stored=$STORED_HEAD" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! openssl dgst -sha256 -verify "$AUDIT_PUB" -signature "$HEAD_SIG" "$HEAD_FILE" >/dev/null 2>&1; then
|
||||
echo "AUDIT_HEAD_SIGNATURE_INVALID head=$COMPUTED_HEAD" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "AUDIT_CHAIN_VALID anchor=signed last_hash=$COMPUTED_HEAD"
|
||||
else
|
||||
echo "AUDIT_CHAIN_VALID anchor=unsigned last_hash=$COMPUTED_HEAD"
|
||||
fi
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Verify CASAN Level 5 shared harness reuse across more than one project.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
REGISTRY="$PROJECT_ROOT/.specify/level5/project-registry.json"
|
||||
PACKAGE="$PROJECT_ROOT/.specify/level5/harness-package.json"
|
||||
|
||||
python3 - "$REGISTRY" "$PACKAGE" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
registry = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
package = json.load(open(sys.argv[2], encoding="utf-8"))
|
||||
name = package["package"]
|
||||
version = package["version"]
|
||||
projects = [
|
||||
p for p in registry["projects"]
|
||||
if p.get("harness_package") == name and p.get("harness_version") == version
|
||||
]
|
||||
if len(projects) < 2:
|
||||
raise SystemExit(f"HARNESS_REUSE_INSUFFICIENT package={name} version={version} count={len(projects)}")
|
||||
print(f"HARNESS_REUSE_VALID package={name} version={version} project_count={len(projects)}")
|
||||
PY
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Verify the tamper-evident central tool-call audit log:
|
||||
# 1. recompute the SHA-256 hash chain
|
||||
# 2. confirm the stored head equals the computed head
|
||||
# 3. verify the head's RSA signature
|
||||
# Usage: verify-tool-audit.sh [tool-calls.jsonl]
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
LOG="${1:-$PROJECT_ROOT/.specify/logs/audit/tool-calls.jsonl}"
|
||||
|
||||
if [[ ! -f "$LOG" ]]; then
|
||||
echo "TOOL_AUDIT_MISSING file=$LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
COMPUTED_HEAD="$(python3 - "$LOG" <<'PY'
|
||||
import hashlib, json, sys
|
||||
path = sys.argv[1]
|
||||
prev = ""
|
||||
count = 0
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line_no, line in enumerate(f, 1):
|
||||
if not line.strip():
|
||||
continue
|
||||
rec = json.loads(line)
|
||||
if rec.get("previous_record_hash", "") != prev:
|
||||
raise SystemExit(f"TOOL_AUDIT_CHAIN_BROKEN line={line_no}")
|
||||
stored = rec.pop("record_hash", "")
|
||||
core = json.dumps(rec, sort_keys=True, separators=(",", ":"))
|
||||
expected = hashlib.sha256((prev + "|" + core).encode()).hexdigest()
|
||||
if expected != stored:
|
||||
raise SystemExit(f"TOOL_AUDIT_HASH_MISMATCH line={line_no}")
|
||||
prev = stored
|
||||
count += 1
|
||||
sys.stderr.write(f"TOOL_AUDIT_INTEGRITY_OK records={count}\n")
|
||||
sys.stdout.write(prev)
|
||||
PY
|
||||
)"
|
||||
|
||||
AUDIT_DIR="$(dirname "$LOG")"
|
||||
HEAD_FILE="$AUDIT_DIR/tool-calls-head.txt"
|
||||
HEAD_SIG="$AUDIT_DIR/tool-calls-head.sig"
|
||||
AUDIT_PUB="$PROJECT_ROOT/.specify/level5/central-governance/audit-public.pem"
|
||||
|
||||
if [[ -f "$HEAD_FILE" && -f "$HEAD_SIG" && -f "$AUDIT_PUB" ]] && command -v openssl >/dev/null 2>&1; then
|
||||
if [[ "$(cat "$HEAD_FILE")" != "$COMPUTED_HEAD" ]]; then
|
||||
echo "TOOL_AUDIT_HEAD_MISMATCH computed=$COMPUTED_HEAD" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! openssl dgst -sha256 -verify "$AUDIT_PUB" -signature "$HEAD_SIG" "$HEAD_FILE" >/dev/null 2>&1; then
|
||||
echo "TOOL_AUDIT_HEAD_SIGNATURE_INVALID head=$COMPUTED_HEAD" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "TOOL_AUDIT_VALID anchor=signed last_hash=$COMPUTED_HEAD"
|
||||
else
|
||||
echo "TOOL_AUDIT_VALID anchor=unsigned last_hash=$COMPUTED_HEAD"
|
||||
fi
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# CASAN H6 AgentOps Harness - PowerShell port of agent-metrics.sh
|
||||
# Usage:
|
||||
# agent-metrics.ps1 <input-file> <output-file> [-- <command> [args...]]
|
||||
#
|
||||
# If command omitted: pass-through copy.
|
||||
# If command provided: runs under timing/cost wrapper.
|
||||
# Exit codes mirror the wrapped command's exit code.
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true, Position=0)][string]$InputFile,
|
||||
[Parameter(Mandatory=$true, Position=1)][string]$OutputFile,
|
||||
[Parameter(ValueFromRemainingArguments=$true)][string[]]$RemainingArgs
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
|
||||
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
|
||||
$logDir = Join-Path $projectRoot ".specify/logs"
|
||||
$traceDir = Join-Path $logDir "trace"
|
||||
$metricsDir = Join-Path $logDir "cost"
|
||||
$alertLog = Join-Path $projectRoot ".specify/agentops/alerts.log"
|
||||
$metricsLog = Join-Path $metricsDir "metrics.jsonl"
|
||||
$toolAudit = Join-Path $logDir "audit/tool-calls.jsonl"
|
||||
|
||||
foreach ($d in @($traceDir, $metricsDir, (Split-Path $OutputFile -Parent), (Split-Path $alertLog -Parent), (Split-Path $toolAudit -Parent))) {
|
||||
if ($d -and !(Test-Path $d)) { New-Item -ItemType Directory -Force -Path $d | Out-Null }
|
||||
}
|
||||
|
||||
if (!(Test-Path $InputFile)) {
|
||||
Write-Error "AGENTOPS_FAILED: input file not found: $InputFile"
|
||||
exit 1
|
||||
}
|
||||
|
||||
function New-TraceId {
|
||||
try { return [System.Guid]::NewGuid().ToString("D") } catch { return "trace-$(Get-Date -Format 'yyyyMMddHHmmss')-$PID" }
|
||||
}
|
||||
|
||||
function Get-Sha256 ([string]$text) {
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
|
||||
$hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes)
|
||||
return ($hash | ForEach-Object { $_.ToString("x2") }) -join ""
|
||||
}
|
||||
|
||||
function Get-WordCount ([string]$text) {
|
||||
return ($text -split '\s+' | Where-Object { $_ -ne "" }).Count
|
||||
}
|
||||
|
||||
function ConvertTo-JsonArray ([string[]]$arr) {
|
||||
if (!$arr -or $arr.Count -eq 0) { return "[]" }
|
||||
$escaped = $arr | ForEach-Object { '"' + ($_ -replace '"','\"') + '"' }
|
||||
return "[" + ($escaped -join ",") + "]"
|
||||
}
|
||||
|
||||
$traceId = New-TraceId
|
||||
$startTs = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
$startMs = [long]([System.DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())
|
||||
$status = "success"
|
||||
$errorMsg = ""
|
||||
$exitCode = 0
|
||||
|
||||
$retryCount = if ($env:CASAN_RETRY_COUNT) { [int]$env:CASAN_RETRY_COUNT } else { 0 }
|
||||
$agentName = if ($env:CASAN_AGENT_NAME) { $env:CASAN_AGENT_NAME } else { "unknown-agent" }
|
||||
$stepName = if ($env:CASAN_STEP_NAME) { $env:CASAN_STEP_NAME } else { "unknown-step" }
|
||||
$modelName = if ($env:CASAN_MODEL_NAME) { $env:CASAN_MODEL_NAME } else { "claude-opus-4-8" }
|
||||
|
||||
$inputContent = Get-Content $InputFile -Raw -Encoding UTF8
|
||||
if (!$inputContent) { $inputContent = "" }
|
||||
$inputTokens = Get-WordCount $inputContent
|
||||
|
||||
# ── Strip leading "--" separator from remaining args ──────────────────────
|
||||
$cmdArgs = $RemainingArgs
|
||||
if ($cmdArgs -and $cmdArgs[0] -eq "--") { $cmdArgs = $cmdArgs[1..($cmdArgs.Count-1)] }
|
||||
|
||||
# ── Execute wrapped command ────────────────────────────────────────────────
|
||||
if ($cmdArgs -and $cmdArgs.Count -gt 0) {
|
||||
$env:CASAN_INPUT = $InputFile
|
||||
$env:CASAN_OUTPUT = $OutputFile
|
||||
|
||||
try {
|
||||
& $cmdArgs[0] $cmdArgs[1..($cmdArgs.Count-1)]
|
||||
$exitCode = $LASTEXITCODE
|
||||
} catch {
|
||||
$exitCode = 1
|
||||
$errorMsg = $_.Exception.Message
|
||||
}
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
$status = "failed"
|
||||
if (!$errorMsg) { $errorMsg = "command exited with code $exitCode" }
|
||||
}
|
||||
|
||||
# Tool call audit log (H2 per-call audit)
|
||||
$cmdStr = ($cmdArgs -join " ") -replace '"','\"'
|
||||
$toolLine = "{`"timestamp`":`"$startTs`",`"trace_id`":`"$traceId`",`"agent`":`"$agentName`",`"step`":`"$stepName`",`"tool`":`"Bash`",`"command`":`"$cmdStr`",`"exit_code`":$exitCode,`"status`":`"$status`"}"
|
||||
Add-Content -Path $toolAudit -Value $toolLine -Encoding UTF8
|
||||
} else {
|
||||
Copy-Item -Path $InputFile -Destination $OutputFile -Force
|
||||
}
|
||||
|
||||
$endMs = [long]([System.DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())
|
||||
$latencyMs = $endMs - $startMs
|
||||
|
||||
if (!(Test-Path $OutputFile)) {
|
||||
$status = "failed"
|
||||
if (!$errorMsg) { $errorMsg = "output file not produced" }
|
||||
Set-Content -Path $OutputFile -Value "" -Encoding UTF8
|
||||
}
|
||||
|
||||
$outputContent = Get-Content $OutputFile -Raw -Encoding UTF8
|
||||
if (!$outputContent) { $outputContent = "" }
|
||||
$outputTokens = Get-WordCount $outputContent
|
||||
$totalTokens = $inputTokens + $outputTokens
|
||||
|
||||
# ── Token estimate v2: word-ratio model (more accurate than pure word count) ─
|
||||
$modelRatios = @{ "claude-opus" = 1.35; "claude-sonnet" = 1.32; "gpt-4" = 1.30 }
|
||||
$modelFamily = if ($modelName -match "opus") { "claude-opus" }
|
||||
elseif ($modelName -match "sonnet") { "claude-sonnet" }
|
||||
else { "claude-opus" }
|
||||
$tokenRatio = $modelRatios[$modelFamily]
|
||||
$tokensEstimated = [int]($totalTokens * $tokenRatio)
|
||||
|
||||
# Cost per 1M tokens (blended input+output estimate)
|
||||
$costPer1M = @{ "claude-opus" = 45.0; "claude-sonnet" = 9.0 }
|
||||
$costRate = $costPer1M[$modelFamily]
|
||||
$costEstimate = [math]::Round($tokensEstimated / 1000000 * $costRate, 8)
|
||||
|
||||
$costPerKOverride = if ($env:CASAN_COST_PER_1K) { [double]$env:CASAN_COST_PER_1K } else { $null }
|
||||
if ($costPerKOverride) { $costEstimate = [math]::Round($totalTokens * $costPerKOverride / 1000, 8) }
|
||||
|
||||
$inputHash = Get-Sha256 $inputContent
|
||||
$outputHash = Get-Sha256 $outputContent
|
||||
|
||||
# ── Alerts ─────────────────────────────────────────────────────────────────
|
||||
$latencyAlertMs = if ($env:CASAN_LATENCY_ALERT_MS) { [int]$env:CASAN_LATENCY_ALERT_MS } else { 5000 }
|
||||
$retryAlertThresh = if ($env:CASAN_RETRY_ALERT_THRESHOLD) { [int]$env:CASAN_RETRY_ALERT_THRESHOLD } else { 2 }
|
||||
$tokenAlertThresh = if ($env:CASAN_TOKEN_ALERT_THRESHOLD) { [int]$env:CASAN_TOKEN_ALERT_THRESHOLD } else { 5000 }
|
||||
|
||||
$alerts = [System.Collections.Generic.List[string]]::new()
|
||||
if ($latencyMs -gt $latencyAlertMs) { $alerts.Add("high-latency") }
|
||||
if ($retryCount -gt $retryAlertThresh) { $alerts.Add("high-retry") }
|
||||
if ($status -eq "failed") { $alerts.Add("execution-failed") }
|
||||
if ($totalTokens -gt $tokenAlertThresh){ $alerts.Add("token-overuse") }
|
||||
$alertsJson = ConvertTo-JsonArray ($alerts.ToArray())
|
||||
|
||||
# ── Trace JSON ─────────────────────────────────────────────────────────────
|
||||
$traceFile = Join-Path $traceDir "agentops-$traceId.json"
|
||||
@"
|
||||
{
|
||||
"trace_id": "$traceId",
|
||||
"timestamp": "$startTs",
|
||||
"harness": "H6-agentops",
|
||||
"agent": "$agentName",
|
||||
"step": "$stepName",
|
||||
"model": "$modelName",
|
||||
"status": "$status",
|
||||
"exit_code": $exitCode,
|
||||
"latency_ms": $latencyMs,
|
||||
"retry_count": $retryCount,
|
||||
"input_tokens": $inputTokens,
|
||||
"output_tokens": $outputTokens,
|
||||
"total_tokens": $totalTokens,
|
||||
"tokens_estimated": $tokensEstimated,
|
||||
"token_estimate_method": "word_ratio_v2",
|
||||
"cost_estimate": $costEstimate,
|
||||
"alerts": $alertsJson,
|
||||
"input_hash": "$inputHash",
|
||||
"output_hash": "$outputHash",
|
||||
"error": "$errorMsg"
|
||||
}
|
||||
"@ | Set-Content -Path $traceFile -Encoding UTF8
|
||||
|
||||
# ── Metrics JSONL ──────────────────────────────────────────────────────────
|
||||
$metricsLine = "{`"timestamp`":`"$startTs`",`"trace_id`":`"$traceId`",`"harness`":`"H6-agentops`",`"agent`":`"$agentName`",`"step`":`"$stepName`",`"status`":`"$status`",`"exit_code`":$exitCode,`"latency_ms`":$latencyMs,`"retry_count`":$retryCount,`"input_tokens`":$inputTokens,`"output_tokens`":$outputTokens,`"total_tokens`":$totalTokens,`"tokens_estimated`":$tokensEstimated,`"cost_estimate`":$costEstimate,`"alerts`":$alertsJson,`"input_hash`":`"$inputHash`",`"output_hash`":`"$outputHash`"}"
|
||||
Add-Content -Path $metricsLog -Value $metricsLine -Encoding UTF8
|
||||
|
||||
# ── Alert log + multi-channel notification ────────────────────────────────
|
||||
foreach ($alert in $alerts) {
|
||||
$alertEntry = "{`"timestamp`":`"$startTs`",`"trace_id`":`"$traceId`",`"severity`":`"WARN`",`"resource`":{`"service.name`":`"$agentName`",`"service.version`":`"1.0.0`"},`"body`":{`"message`":`"Alert triggered: $alert`",`"alert.type`":`"$alert`",`"step.name`":`"$stepName`"},`"attributes`":{`"latency_ms`":$latencyMs,`"status`":`"$status`"}}"
|
||||
Add-Content -Path $alertLog -Value $alertEntry -Encoding UTF8
|
||||
|
||||
# Console notification (always on for interactive runs)
|
||||
Write-Warning "[CASAN ALERT] $alert | step=$stepName agent=$agentName latency=${latencyMs}ms"
|
||||
|
||||
# Webhook (if configured)
|
||||
if ($env:CASAN_ALERT_WEBHOOK_URL) {
|
||||
try {
|
||||
$body = @{ text = "CASAN Alert [$alert]: $stepName — $agentName" } | ConvertTo-Json
|
||||
Invoke-RestMethod -Uri $env:CASAN_ALERT_WEBHOOK_URL -Method POST -Body $body -ContentType "application/json" -ErrorAction SilentlyContinue
|
||||
} catch { <# fire-and-forget #> }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output "AGENTOPS_RECORDED trace_id=$traceId status=$status latency_ms=$latencyMs tokens=$totalTokens estimated_tokens=$tokensEstimated cost=$costEstimate output=$OutputFile"
|
||||
exit $exitCode
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# CASAN unified harness wrapper - PowerShell port of casan-harness.sh
|
||||
# Usage:
|
||||
# casan-harness.ps1 <input-file> <output-file> [action-name] [-- <command> [args...]]
|
||||
#
|
||||
# Runs: H4-input → H5-governance → H6-metrics(real cmd) → H4-output
|
||||
# Includes idempotency caching without bypassing H4/H5/H4-output gates.
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true, Position=0)][string]$InputFile,
|
||||
[Parameter(Mandatory=$true, Position=1)][string]$FinalOutput,
|
||||
[Parameter(Position=2)][string]$ActionName = "agent_step",
|
||||
[Parameter(ValueFromRemainingArguments=$true)][string[]]$RemainingArgs
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
|
||||
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
|
||||
$tmpDir = Join-Path $projectRoot ".specify/logs/tmp"
|
||||
$cacheDir = Join-Path $projectRoot ".specify/logs/idempotency"
|
||||
|
||||
foreach ($d in @($tmpDir, $cacheDir, (Split-Path $FinalOutput -Parent))) {
|
||||
if ($d -and !(Test-Path $d)) { New-Item -ItemType Directory -Force -Path $d | Out-Null }
|
||||
}
|
||||
|
||||
function Get-Sha256 ([string]$text) {
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
|
||||
$hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes)
|
||||
return ($hash | ForEach-Object { $_.ToString("x2") }) -join ""
|
||||
}
|
||||
|
||||
# Strip "--" separator
|
||||
$cmdArgs = $RemainingArgs
|
||||
if ($cmdArgs -and $cmdArgs[0] -eq "--") { $cmdArgs = $cmdArgs[1..($cmdArgs.Count-1)] }
|
||||
|
||||
# ── Idempotency check ──────────────────────────────────────────────────────
|
||||
$inputContent = Get-Content $InputFile -Raw -Encoding UTF8
|
||||
if (!$inputContent) { $inputContent = "" }
|
||||
$cmdStr = if ($cmdArgs) { $cmdArgs -join " " } else { "no_cmd" }
|
||||
$inputHash = Get-Sha256 $inputContent
|
||||
$cmdHash = Get-Sha256 $cmdStr
|
||||
$idemKey = Get-Sha256 "$inputHash|$cmdHash|$ActionName"
|
||||
|
||||
$cacheMeta = Join-Path $cacheDir "$idemKey.json"
|
||||
$cacheOut = Join-Path $cacheDir "$idemKey.output"
|
||||
|
||||
# ── Normal flow ────────────────────────────────────────────────────────────
|
||||
$suffix = "$(Get-Date -Format 'yyyyMMddHHmmss')-$PID"
|
||||
$safeInput = Join-Path $tmpDir "security-input-$suffix.txt"
|
||||
$approvedIn = Join-Path $tmpDir "governance-approved-$suffix.txt"
|
||||
$rawOutput = Join-Path $tmpDir "raw-output-$suffix.txt"
|
||||
|
||||
# H4 input security
|
||||
& "$scriptDir/security-check.ps1" $InputFile $safeInput input
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
# H5 governance
|
||||
& "$scriptDir/governance-check.ps1" $safeInput $approvedIn $ActionName
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
# H6 metrics around real command or cached output.
|
||||
if ((Test-Path $cacheMeta) -and (Test-Path $cacheOut)) {
|
||||
Copy-Item -Path $cacheOut -Destination $rawOutput -Force
|
||||
$metricsExit = 0
|
||||
$cacheStatus = "cached"
|
||||
} elseif ($cmdArgs -and $cmdArgs.Count -gt 0) {
|
||||
& "$scriptDir/agent-metrics.ps1" $approvedIn $rawOutput "--" @cmdArgs
|
||||
$metricsExit = $LASTEXITCODE
|
||||
$cacheStatus = "stored"
|
||||
} else {
|
||||
& "$scriptDir/agent-metrics.ps1" $approvedIn $rawOutput
|
||||
$metricsExit = $LASTEXITCODE
|
||||
$cacheStatus = "stored"
|
||||
}
|
||||
|
||||
# H4 output security
|
||||
& "$scriptDir/security-check.ps1" $rawOutput $FinalOutput output
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
# ── Save idempotency cache ─────────────────────────────────────────────────
|
||||
$outputContent = Get-Content $FinalOutput -Raw -Encoding UTF8
|
||||
if (!$outputContent) { $outputContent = "" }
|
||||
$outputHash = Get-Sha256 $outputContent
|
||||
|
||||
if ($cacheStatus -eq "stored") {
|
||||
@"
|
||||
{
|
||||
"idempotency_key": "$idemKey",
|
||||
"timestamp": "$($(Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ"))",
|
||||
"action": "$ActionName",
|
||||
"command": "$(($cmdStr -replace '"','\"'))",
|
||||
"output_hash": "$outputHash"
|
||||
}
|
||||
"@ | Set-Content -Path $cacheMeta -Encoding UTF8
|
||||
Copy-Item -Path $FinalOutput -Destination $cacheOut -Force
|
||||
}
|
||||
|
||||
# Clean up tmp files
|
||||
foreach ($f in @($safeInput, $approvedIn, $rawOutput)) {
|
||||
if (Test-Path $f) { Remove-Item $f -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
Write-Output "CASAN_HARNESS_COMPLETE cache=$cacheStatus key=$idemKey output=$FinalOutput"
|
||||
exit $metricsExit
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env pwsh
|
||||
|
||||
# Consolidated prerequisite checking script (PowerShell)
|
||||
#
|
||||
# This script provides unified prerequisite checking for Spec-Driven Development workflow.
|
||||
# It replaces the functionality previously spread across multiple scripts.
|
||||
#
|
||||
# Usage: ./check-prerequisites.ps1 [OPTIONS]
|
||||
#
|
||||
# OPTIONS:
|
||||
# -Json Output in JSON format
|
||||
# -RequireTasks Require tasks.md to exist (for implementation phase)
|
||||
# -IncludeTasks Include tasks.md in AVAILABLE_DOCS list
|
||||
# -PathsOnly Only output path variables (no validation)
|
||||
# -Help, -h Show help message
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Json,
|
||||
[switch]$RequireTasks,
|
||||
[switch]$IncludeTasks,
|
||||
[switch]$PathsOnly,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Show help if requested
|
||||
if ($Help) {
|
||||
Write-Output @"
|
||||
Usage: check-prerequisites.ps1 [OPTIONS]
|
||||
|
||||
Consolidated prerequisite checking for Spec-Driven Development workflow.
|
||||
|
||||
OPTIONS:
|
||||
-Json Output in JSON format
|
||||
-RequireTasks Require tasks.md to exist (for implementation phase)
|
||||
-IncludeTasks Include tasks.md in AVAILABLE_DOCS list
|
||||
-PathsOnly Only output path variables (no prerequisite validation)
|
||||
-Help, -h Show this help message
|
||||
|
||||
EXAMPLES:
|
||||
# Check task prerequisites (plan.md required)
|
||||
.\check-prerequisites.ps1 -Json
|
||||
|
||||
# Check implementation prerequisites (plan.md + tasks.md required)
|
||||
.\check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
||||
|
||||
# Get feature paths only (no validation)
|
||||
.\check-prerequisites.ps1 -PathsOnly
|
||||
|
||||
"@
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Source common functions
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Get feature paths and validate branch
|
||||
$paths = Get-FeaturePathsEnv
|
||||
|
||||
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# If paths-only mode, output paths and exit (support combined -Json -PathsOnly)
|
||||
if ($PathsOnly) {
|
||||
if ($Json) {
|
||||
[PSCustomObject]@{
|
||||
REPO_ROOT = $paths.REPO_ROOT
|
||||
BRANCH = $paths.CURRENT_BRANCH
|
||||
FEATURE_DIR = $paths.FEATURE_DIR
|
||||
FEATURE_SPEC = $paths.FEATURE_SPEC
|
||||
IMPL_PLAN = $paths.IMPL_PLAN
|
||||
TASKS = $paths.TASKS
|
||||
} | ConvertTo-Json -Compress
|
||||
} else {
|
||||
Write-Output "REPO_ROOT: $($paths.REPO_ROOT)"
|
||||
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
|
||||
Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)"
|
||||
Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)"
|
||||
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
|
||||
Write-Output "TASKS: $($paths.TASKS)"
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Validate required directories and files
|
||||
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
|
||||
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"
|
||||
Write-Output "Run /speckit.specify first to create the feature structure."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||
Write-Output "ERROR: plan.md not found in $($paths.FEATURE_DIR)"
|
||||
Write-Output "Run /speckit.plan first to create the implementation plan."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check for tasks.md if required
|
||||
if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
|
||||
Write-Output "ERROR: tasks.md not found in $($paths.FEATURE_DIR)"
|
||||
Write-Output "Run /speckit.tasks first to create the task list."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Build list of available documents
|
||||
$docs = @()
|
||||
|
||||
# Always check these optional docs
|
||||
if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
|
||||
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
|
||||
|
||||
# Check contracts directory (only if it exists and has files)
|
||||
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
|
||||
$docs += 'contracts/'
|
||||
}
|
||||
|
||||
if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
|
||||
|
||||
# Include tasks.md if requested and it exists
|
||||
if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
|
||||
$docs += 'tasks.md'
|
||||
}
|
||||
|
||||
# Output results
|
||||
if ($Json) {
|
||||
# JSON output
|
||||
[PSCustomObject]@{
|
||||
FEATURE_DIR = $paths.FEATURE_DIR
|
||||
AVAILABLE_DOCS = $docs
|
||||
} | ConvertTo-Json -Compress
|
||||
} else {
|
||||
# Text output
|
||||
Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)"
|
||||
Write-Output "AVAILABLE_DOCS:"
|
||||
|
||||
# Show status of each potential document
|
||||
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null
|
||||
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null
|
||||
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null
|
||||
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null
|
||||
|
||||
if ($IncludeTasks) {
|
||||
Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Out-Null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Common PowerShell functions analogous to common.sh
|
||||
|
||||
function Get-RepoRoot {
|
||||
try {
|
||||
$result = git rev-parse --show-toplevel 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
return $result
|
||||
}
|
||||
} catch {
|
||||
# Git command failed
|
||||
}
|
||||
|
||||
# Fall back to script location for non-git repos
|
||||
return (Resolve-Path (Join-Path $PSScriptRoot "../../..")).Path
|
||||
}
|
||||
|
||||
function Get-CurrentBranch {
|
||||
# First check if SPECIFY_FEATURE environment variable is set
|
||||
if ($env:SPECIFY_FEATURE) {
|
||||
return $env:SPECIFY_FEATURE
|
||||
}
|
||||
|
||||
# Then check git if available
|
||||
try {
|
||||
$result = git rev-parse --abbrev-ref HEAD 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
return $result
|
||||
}
|
||||
} catch {
|
||||
# Git command failed
|
||||
}
|
||||
|
||||
# For non-git repos, try to find the latest feature directory
|
||||
$repoRoot = Get-RepoRoot
|
||||
$specsDir = Join-Path $repoRoot "specs"
|
||||
|
||||
if (Test-Path $specsDir) {
|
||||
$latestFeature = ""
|
||||
$highest = 0
|
||||
|
||||
Get-ChildItem -Path $specsDir -Directory | ForEach-Object {
|
||||
if ($_.Name -match '^(\d{3})-') {
|
||||
$num = [int]$matches[1]
|
||||
if ($num -gt $highest) {
|
||||
$highest = $num
|
||||
$latestFeature = $_.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($latestFeature) {
|
||||
return $latestFeature
|
||||
}
|
||||
}
|
||||
|
||||
# Final fallback
|
||||
return "main"
|
||||
}
|
||||
|
||||
function Test-HasGit {
|
||||
try {
|
||||
git rev-parse --show-toplevel 2>$null | Out-Null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Test-FeatureBranch {
|
||||
param(
|
||||
[string]$Branch,
|
||||
[bool]$HasGit = $true
|
||||
)
|
||||
|
||||
# For non-git repos, we can't enforce branch naming but still provide output
|
||||
if (-not $HasGit) {
|
||||
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
|
||||
return $true
|
||||
}
|
||||
|
||||
if ($Branch -notmatch '^[0-9]{3}-') {
|
||||
Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
|
||||
Write-Output "Feature branches should be named like: 001-feature-name"
|
||||
return $false
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Get-FeatureDir {
|
||||
param([string]$RepoRoot, [string]$Branch)
|
||||
Join-Path $RepoRoot "specs/$Branch"
|
||||
}
|
||||
|
||||
function Get-FeaturePathsEnv {
|
||||
$repoRoot = Get-RepoRoot
|
||||
$currentBranch = Get-CurrentBranch
|
||||
$hasGit = Test-HasGit
|
||||
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
|
||||
|
||||
[PSCustomObject]@{
|
||||
REPO_ROOT = $repoRoot
|
||||
CURRENT_BRANCH = $currentBranch
|
||||
HAS_GIT = $hasGit
|
||||
FEATURE_DIR = $featureDir
|
||||
FEATURE_SPEC = Join-Path $featureDir 'spec.md'
|
||||
IMPL_PLAN = Join-Path $featureDir 'plan.md'
|
||||
TASKS = Join-Path $featureDir 'tasks.md'
|
||||
RESEARCH = Join-Path $featureDir 'research.md'
|
||||
DATA_MODEL = Join-Path $featureDir 'data-model.md'
|
||||
QUICKSTART = Join-Path $featureDir 'quickstart.md'
|
||||
CONTRACTS_DIR = Join-Path $featureDir 'contracts'
|
||||
}
|
||||
}
|
||||
|
||||
function Test-FileExists {
|
||||
param([string]$Path, [string]$Description)
|
||||
if (Test-Path -Path $Path -PathType Leaf) {
|
||||
Write-Output " ✓ $Description"
|
||||
return $true
|
||||
} else {
|
||||
Write-Output " ✗ $Description"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Test-DirHasFiles {
|
||||
param([string]$Path, [string]$Description)
|
||||
if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } | Select-Object -First 1)) {
|
||||
Write-Output " ✓ $Description"
|
||||
return $true
|
||||
} else {
|
||||
Write-Output " ✗ $Description"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Create a new feature
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Json,
|
||||
[string]$ShortName,
|
||||
[int]$Number = 0,
|
||||
[switch]$Help,
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$FeatureDescription
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Show help if requested
|
||||
if ($Help) {
|
||||
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] <feature description>"
|
||||
Write-Host ""
|
||||
Write-Host "Options:"
|
||||
Write-Host " -Json Output in JSON format"
|
||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
|
||||
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
||||
Write-Host " -Help Show this help message"
|
||||
Write-Host ""
|
||||
Write-Host "Examples:"
|
||||
Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'"
|
||||
Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Check if feature description provided
|
||||
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
||||
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] <feature description>"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$featureDesc = ($FeatureDescription -join ' ').Trim()
|
||||
|
||||
# Validate description is not empty after trimming (e.g., user passed only whitespace)
|
||||
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
|
||||
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Resolve repository root. Prefer git information when available, but fall back
|
||||
# to searching for repository markers so the workflow still functions in repositories that
|
||||
# were initialized with --no-git.
|
||||
function Find-RepositoryRoot {
|
||||
param(
|
||||
[string]$StartDir,
|
||||
[string[]]$Markers = @('.git', '.specify')
|
||||
)
|
||||
$current = Resolve-Path $StartDir
|
||||
while ($true) {
|
||||
foreach ($marker in $Markers) {
|
||||
if (Test-Path (Join-Path $current $marker)) {
|
||||
return $current
|
||||
}
|
||||
}
|
||||
$parent = Split-Path $current -Parent
|
||||
if ($parent -eq $current) {
|
||||
# Reached filesystem root without finding markers
|
||||
return $null
|
||||
}
|
||||
$current = $parent
|
||||
}
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromSpecs {
|
||||
param([string]$SpecsDir)
|
||||
|
||||
$highest = 0
|
||||
if (Test-Path $SpecsDir) {
|
||||
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
||||
if ($_.Name -match '^(\d+)') {
|
||||
$num = [int]$matches[1]
|
||||
if ($num -gt $highest) { $highest = $num }
|
||||
}
|
||||
}
|
||||
}
|
||||
return $highest
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromBranches {
|
||||
param()
|
||||
|
||||
$highest = 0
|
||||
try {
|
||||
$branches = git branch -a 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
foreach ($branch in $branches) {
|
||||
# Clean branch name: remove leading markers and remote prefixes
|
||||
$cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||
|
||||
# Extract feature number if branch matches pattern ###-*
|
||||
if ($cleanBranch -match '^(\d+)-') {
|
||||
$num = [int]$matches[1]
|
||||
if ($num -gt $highest) { $highest = $num }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
# If git command fails, return 0
|
||||
Write-Verbose "Could not check Git branches: $_"
|
||||
}
|
||||
return $highest
|
||||
}
|
||||
|
||||
function Get-NextBranchNumber {
|
||||
param(
|
||||
[string]$SpecsDir
|
||||
)
|
||||
|
||||
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
||||
try {
|
||||
git fetch --all --prune 2>$null | Out-Null
|
||||
} catch {
|
||||
# Ignore fetch errors
|
||||
}
|
||||
|
||||
# Get highest number from ALL branches (not just matching short name)
|
||||
$highestBranch = Get-HighestNumberFromBranches
|
||||
|
||||
# Get highest number from ALL specs (not just matching short name)
|
||||
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
|
||||
|
||||
# Take the maximum of both
|
||||
$maxNum = [Math]::Max($highestBranch, $highestSpec)
|
||||
|
||||
# Return next number
|
||||
return $maxNum + 1
|
||||
}
|
||||
|
||||
function ConvertTo-CleanBranchName {
|
||||
param([string]$Name)
|
||||
|
||||
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
||||
}
|
||||
$fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot)
|
||||
if (-not $fallbackRoot) {
|
||||
Write-Error "Error: Could not determine repository root. Please run this script from within the repository."
|
||||
exit 1
|
||||
}
|
||||
|
||||
try {
|
||||
$repoRoot = git rev-parse --show-toplevel 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$hasGit = $true
|
||||
} else {
|
||||
throw "Git not available"
|
||||
}
|
||||
} catch {
|
||||
$repoRoot = $fallbackRoot
|
||||
$hasGit = $false
|
||||
}
|
||||
|
||||
Set-Location $repoRoot
|
||||
|
||||
$specsDir = Join-Path $repoRoot 'specs'
|
||||
New-Item -ItemType Directory -Path $specsDir -Force | Out-Null
|
||||
|
||||
# Function to generate branch name with stop word filtering and length filtering
|
||||
function Get-BranchName {
|
||||
param([string]$Description)
|
||||
|
||||
# Common stop words to filter out
|
||||
$stopWords = @(
|
||||
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
|
||||
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
|
||||
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
|
||||
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
|
||||
'want', 'need', 'add', 'get', 'set'
|
||||
)
|
||||
|
||||
# Convert to lowercase and extract words (alphanumeric only)
|
||||
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
|
||||
$words = $cleanName -split '\s+' | Where-Object { $_ }
|
||||
|
||||
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
|
||||
$meaningfulWords = @()
|
||||
foreach ($word in $words) {
|
||||
# Skip stop words
|
||||
if ($stopWords -contains $word) { continue }
|
||||
|
||||
# Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms)
|
||||
if ($word.Length -ge 3) {
|
||||
$meaningfulWords += $word
|
||||
} elseif ($Description -match "\b$($word.ToUpper())\b") {
|
||||
# Keep short words if they appear as uppercase in original (likely acronyms)
|
||||
$meaningfulWords += $word
|
||||
}
|
||||
}
|
||||
|
||||
# If we have meaningful words, use first 3-4 of them
|
||||
if ($meaningfulWords.Count -gt 0) {
|
||||
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
|
||||
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
|
||||
return $result
|
||||
} else {
|
||||
# Fallback to original logic if no meaningful words found
|
||||
$result = ConvertTo-CleanBranchName -Name $Description
|
||||
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
|
||||
return [string]::Join('-', $fallbackWords)
|
||||
}
|
||||
}
|
||||
|
||||
# Generate branch name
|
||||
if ($ShortName) {
|
||||
# Use provided short name, just clean it up
|
||||
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
|
||||
} else {
|
||||
# Generate from description with smart filtering
|
||||
$branchSuffix = Get-BranchName -Description $featureDesc
|
||||
}
|
||||
|
||||
# Determine branch number
|
||||
if ($Number -eq 0) {
|
||||
if ($hasGit) {
|
||||
# Check existing branches on remotes
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
||||
} else {
|
||||
# Fall back to local directory check
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
}
|
||||
}
|
||||
|
||||
$featureNum = ('{0:000}' -f $Number)
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names
|
||||
# Validate and truncate if necessary
|
||||
$maxBranchLength = 244
|
||||
if ($branchName.Length -gt $maxBranchLength) {
|
||||
# Calculate how much we need to trim from suffix
|
||||
# Account for: feature number (3) + hyphen (1) = 4 chars
|
||||
$maxSuffixLength = $maxBranchLength - 4
|
||||
|
||||
# Truncate suffix
|
||||
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
|
||||
# Remove trailing hyphen if truncation created one
|
||||
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
|
||||
|
||||
$originalBranchName = $branchName
|
||||
$branchName = "$featureNum-$truncatedSuffix"
|
||||
|
||||
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
|
||||
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
|
||||
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
|
||||
}
|
||||
|
||||
if ($hasGit) {
|
||||
$branchCreated = $false
|
||||
try {
|
||||
git checkout -b $branchName 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$branchCreated = $true
|
||||
}
|
||||
} catch {
|
||||
# Exception during git command
|
||||
}
|
||||
|
||||
if (-not $branchCreated) {
|
||||
# Check if branch already exists
|
||||
$existingBranch = git branch --list $branchName 2>$null
|
||||
if ($existingBranch) {
|
||||
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
|
||||
exit 1
|
||||
} else {
|
||||
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
|
||||
}
|
||||
|
||||
$featureDir = Join-Path $specsDir $branchName
|
||||
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
||||
|
||||
$template = Join-Path $repoRoot '.specify/templates/spec-template.md'
|
||||
$specFile = Join-Path $featureDir 'spec.md'
|
||||
if (Test-Path $template) {
|
||||
Copy-Item $template $specFile -Force
|
||||
} else {
|
||||
New-Item -ItemType File -Path $specFile | Out-Null
|
||||
}
|
||||
|
||||
# Set the SPECIFY_FEATURE environment variable for the current session
|
||||
$env:SPECIFY_FEATURE = $branchName
|
||||
|
||||
if ($Json) {
|
||||
$obj = [PSCustomObject]@{
|
||||
BRANCH_NAME = $branchName
|
||||
SPEC_FILE = $specFile
|
||||
FEATURE_NUM = $featureNum
|
||||
HAS_GIT = $hasGit
|
||||
}
|
||||
$obj | ConvertTo-Json -Compress
|
||||
} else {
|
||||
Write-Output "BRANCH_NAME: $branchName"
|
||||
Write-Output "SPEC_FILE: $specFile"
|
||||
Write-Output "FEATURE_NUM: $featureNum"
|
||||
Write-Output "HAS_GIT: $hasGit"
|
||||
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# CASAN L5 Drift Detector - PowerShell port of drift-detect.sh
|
||||
# Usage:
|
||||
# drift-detect.ps1 <golden-file> <candidate-file> <report-json>
|
||||
#
|
||||
# Exit codes: 0=pass/warn, 2=drift-fail
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true, Position=0)][string]$GoldenFile,
|
||||
[Parameter(Mandatory=$true, Position=1)][string]$CandidateFile,
|
||||
[Parameter(Mandatory=$true, Position=2)][string]$ReportFile
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
|
||||
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
|
||||
$l5LogDir = Join-Path $projectRoot ".specify/logs/level5"
|
||||
|
||||
foreach ($d in @($l5LogDir, (Split-Path $ReportFile -Parent))) {
|
||||
if ($d -and !(Test-Path $d)) { New-Item -ItemType Directory -Force -Path $d | Out-Null }
|
||||
}
|
||||
|
||||
if (!(Test-Path $GoldenFile)) { Write-Error "Golden file not found: $GoldenFile"; exit 1 }
|
||||
if (!(Test-Path $CandidateFile)) { Write-Error "Candidate file not found: $CandidateFile"; exit 1 }
|
||||
|
||||
function Get-Sha256 ([string]$text) {
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
|
||||
$hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes)
|
||||
return ($hash | ForEach-Object { $_.ToString("x2") }) -join ""
|
||||
}
|
||||
|
||||
# Levenshtein-inspired similarity via longest common subsequence on words
|
||||
function Get-SimilarityRatio ([string]$a, [string]$b) {
|
||||
$wordsA = $a -split '\s+' | Where-Object { $_ }
|
||||
$wordsB = $b -split '\s+' | Where-Object { $_ }
|
||||
if ($wordsA.Count -eq 0 -and $wordsB.Count -eq 0) { return 1.0 }
|
||||
if ($wordsA.Count -eq 0 -or $wordsB.Count -eq 0) { return 0.0 }
|
||||
|
||||
# Simple Jaccard similarity on word sets (fast, portable, no python needed)
|
||||
$setA = [System.Collections.Generic.HashSet[string]]$wordsA
|
||||
$setB = [System.Collections.Generic.HashSet[string]]$wordsB
|
||||
$intersection = ($setA | Where-Object { $setB.Contains($_) }).Count
|
||||
$union = ($setA + $setB | Sort-Object -Unique).Count
|
||||
return [math]::Round($intersection / [math]::Max($union, 1), 4)
|
||||
}
|
||||
|
||||
$golden = Get-Content $GoldenFile -Raw -Encoding UTF8
|
||||
$candidate = Get-Content $CandidateFile -Raw -Encoding UTF8
|
||||
if (!$golden) { $golden = "" }
|
||||
if (!$candidate) { $candidate = "" }
|
||||
|
||||
$similarity = Get-SimilarityRatio $golden $candidate
|
||||
$lenGolden = [math]::Max($golden.Length, 1)
|
||||
$lengthDelta = [math]::Round([math]::Abs($candidate.Length - $golden.Length) / $lenGolden, 4)
|
||||
|
||||
$driftStatus = "pass"
|
||||
$driftAction = "allow"
|
||||
if ($similarity -lt 0.70 -or $lengthDelta -gt 0.50) {
|
||||
$driftStatus = "fail"; $driftAction = "block_or_fallback"
|
||||
} elseif ($similarity -lt 0.85 -or $lengthDelta -gt 0.30) {
|
||||
$driftStatus = "warn"; $driftAction = "require_review"
|
||||
}
|
||||
|
||||
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
$goldenHash = Get-Sha256 $golden
|
||||
$candidHash = Get-Sha256 $candidate
|
||||
|
||||
$report = @"
|
||||
{
|
||||
"timestamp": "$timestamp",
|
||||
"harness": "L5-drift-detection",
|
||||
"status": "$driftStatus",
|
||||
"action": "$driftAction",
|
||||
"similarity_ratio": $similarity,
|
||||
"length_delta_ratio": $lengthDelta,
|
||||
"golden_hash": "$goldenHash",
|
||||
"candidate_hash": "$candidHash",
|
||||
"golden_file": "$GoldenFile",
|
||||
"candidate_file": "$CandidateFile"
|
||||
}
|
||||
"@
|
||||
|
||||
Set-Content -Path $ReportFile -Value $report -Encoding UTF8
|
||||
|
||||
# Append to L5 log
|
||||
$logLine = "{`"timestamp`":`"$timestamp`",`"harness`":`"L5-drift-detection`",`"status`":`"$driftStatus`",`"similarity`":$similarity,`"length_delta`":$lengthDelta,`"golden`":`"$GoldenFile`",`"candidate`":`"$CandidateFile`"}"
|
||||
Add-Content -Path (Join-Path $l5LogDir "drift.jsonl") -Value $logLine -Encoding UTF8
|
||||
|
||||
Write-Output "DRIFT_$($driftStatus.ToUpper()) similarity=$similarity length_delta=$lengthDelta report=$ReportFile"
|
||||
|
||||
if ($driftStatus -eq "fail") { exit 2 }
|
||||
exit 0
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# CASAN H6 AgentOps Dashboard Generator (auto-refresh HTML)
|
||||
# Usage:
|
||||
# generate-agentops-dashboard.ps1 [-FeatureId <id>] [-OutputHtml <path>]
|
||||
|
||||
param(
|
||||
[string]$FeatureId = "latest",
|
||||
[string]$OutputHtml = ""
|
||||
)
|
||||
|
||||
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
|
||||
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
|
||||
$metricsLog = Join-Path $projectRoot ".specify/logs/cost/metrics.jsonl"
|
||||
$alertLog = Join-Path $projectRoot ".specify/agentops/alerts.log"
|
||||
|
||||
if (!$OutputHtml) { $OutputHtml = Join-Path $projectRoot "docs/output/casan/agentops-dashboard.html" }
|
||||
if (!(Test-Path (Split-Path $OutputHtml -Parent))) { New-Item -ItemType Directory -Force -Path (Split-Path $OutputHtml -Parent) | Out-Null }
|
||||
|
||||
$generatedAt = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
|
||||
# ── Read metrics ───────────────────────────────────────────────────────────
|
||||
$metrics = @()
|
||||
if (Test-Path $metricsLog) {
|
||||
$metrics = Get-Content $metricsLog | ForEach-Object {
|
||||
try { $_ | ConvertFrom-Json } catch { $null }
|
||||
} | Where-Object { $_ }
|
||||
}
|
||||
|
||||
$alerts = @()
|
||||
if (Test-Path $alertLog) {
|
||||
$alerts = Get-Content $alertLog | ForEach-Object {
|
||||
try { $_ | ConvertFrom-Json } catch { $null }
|
||||
} | Where-Object { $_ }
|
||||
}
|
||||
|
||||
# ── Aggregations ───────────────────────────────────────────────────────────
|
||||
$totalRuns = $metrics.Count
|
||||
$totalCost = [math]::Round(($metrics | Measure-Object -Property cost_estimate -Sum).Sum, 4)
|
||||
$totalTokens = ($metrics | Measure-Object -Property tokens_estimated -Sum).Sum
|
||||
$avgLatency = if ($totalRuns) { [math]::Round(($metrics | Measure-Object -Property latency_ms -Average).Average, 0) } else { 0 }
|
||||
$failedRuns = ($metrics | Where-Object { $_.status -eq "failed" }).Count
|
||||
$passRate = if ($totalRuns) { [math]::Round(($totalRuns - $failedRuns) / $totalRuns * 100, 1) } else { 100 }
|
||||
$totalAlerts = $alerts.Count
|
||||
|
||||
# Per-step table rows
|
||||
$stepRows = $metrics | Group-Object step | ForEach-Object {
|
||||
$g = $_
|
||||
$avgLat = [math]::Round(($g.Group | Measure-Object latency_ms -Average).Average, 0)
|
||||
$totCost = [math]::Round(($g.Group | Measure-Object cost_estimate -Sum).Sum, 4)
|
||||
$runs = $g.Group.Count
|
||||
$fails = ($g.Group | Where-Object { $_.status -eq "failed" }).Count
|
||||
"<tr><td>$($g.Name)</td><td>$runs</td><td>${avgLat}ms</td><td>$$totCost</td><td>$fails</td></tr>"
|
||||
}
|
||||
|
||||
# Alert rows
|
||||
$alertRows = $alerts | Select-Object -Last 20 | ForEach-Object {
|
||||
$sev = if ($_.severity -eq "WARN") { "style='color:orange'" } else { "style='color:red'" }
|
||||
$msg = $_.body.message
|
||||
"<tr><td $sev>$($_.severity)</td><td>$($_.timestamp)</td><td>$msg</td></tr>"
|
||||
}
|
||||
|
||||
$html = @"
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>CASAN AgentOps Dashboard — $FeatureId</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background:#f5f7fa; margin:0; padding:20px; color:#333 }
|
||||
h1 { color:#1a56db; margin-bottom:4px }
|
||||
.meta { color:#6b7280; font-size:0.85em; margin-bottom:24px }
|
||||
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:16px; margin-bottom:28px }
|
||||
.card { background:#fff; border-radius:12px; padding:20px; box-shadow:0 1px 4px rgba(0,0,0,.08); text-align:center }
|
||||
.card .val { font-size:2em; font-weight:700; color:#1a56db }
|
||||
.card .lbl { font-size:0.8em; color:#6b7280; margin-top:4px }
|
||||
.card.warn .val { color:#d97706 }
|
||||
.card.fail .val { color:#dc2626 }
|
||||
table { width:100%; border-collapse:collapse; background:#fff; border-radius:12px; overflow:hidden; box-shadow:0 1px 4px rgba(0,0,0,.08); margin-bottom:24px }
|
||||
th { background:#1a56db; color:#fff; padding:10px 14px; text-align:left; font-size:0.85em }
|
||||
td { padding:9px 14px; border-bottom:1px solid #f0f0f0; font-size:0.9em }
|
||||
tr:last-child td { border-bottom:none }
|
||||
h2 { color:#374151; margin:24px 0 8px }
|
||||
.badge { display:inline-block; padding:2px 8px; border-radius:99px; font-size:0.75em; font-weight:600 }
|
||||
.badge.pass { background:#d1fae5; color:#065f46 }
|
||||
.badge.fail { background:#fee2e2; color:#991b1b }
|
||||
footer { color:#9ca3af; font-size:0.78em; margin-top:32px }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>CASAN AgentOps Dashboard</h1>
|
||||
<div class="meta">Feature: <strong>$FeatureId</strong> | Generated: $generatedAt</div>
|
||||
|
||||
<div class="cards">
|
||||
<div class="card"><div class="val">$totalRuns</div><div class="lbl">Total Runs</div></div>
|
||||
<div class="card $(if($passRate -lt 80){'fail'} elseif($passRate -lt 95){'warn'} else {''})"><div class="val">${passRate}%</div><div class="lbl">Pass Rate</div></div>
|
||||
<div class="card"><div class="val">${avgLatency}ms</div><div class="lbl">Avg Latency</div></div>
|
||||
<div class="card"><div class="val">$totalTokens</div><div class="lbl">Total Tokens (est.)</div></div>
|
||||
<div class="card $(if($totalCost -gt 1){'warn'} else {''})"><div class="val">\$$totalCost</div><div class="lbl">Est. Cost (USD)</div></div>
|
||||
<div class="card $(if($totalAlerts -gt 0){'warn'} else {''})"><div class="val">$totalAlerts</div><div class="lbl">Alerts</div></div>
|
||||
</div>
|
||||
|
||||
<h2>Per-Step Breakdown</h2>
|
||||
<table>
|
||||
<thead><tr><th>Step</th><th>Runs</th><th>Avg Latency</th><th>Est. Cost</th><th>Failures</th></tr></thead>
|
||||
<tbody>$($stepRows -join "`n")</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Recent Alerts (last 20)</h2>
|
||||
<table>
|
||||
<thead><tr><th>Severity</th><th>Timestamp</th><th>Message</th></tr></thead>
|
||||
<tbody>$(if($alertRows){$alertRows -join "`n"} else {"<tr><td colspan='3' style='color:#10b981;text-align:center'>No alerts — all clear ✓</td></tr>"})</tbody>
|
||||
</table>
|
||||
|
||||
<footer>CASAN Level 4 AgentOps Harness | Auto-generated — do not edit manually | $generatedAt</footer>
|
||||
</body>
|
||||
</html>
|
||||
"@
|
||||
|
||||
Set-Content -Path $OutputHtml -Value $html -Encoding UTF8
|
||||
Write-Output "AGENTOPS_DASHBOARD_GENERATED: $OutputHtml"
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# CASAN H5 Compliance Report Generator
|
||||
# Usage:
|
||||
# generate-compliance-report.ps1 -FeatureId <id> -OutputPath <path>
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$FeatureId,
|
||||
[string]$OutputPath = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
|
||||
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
|
||||
$auditLog = Join-Path $projectRoot ".specify/logs/audit/audit.jsonl"
|
||||
$secAudit = Join-Path $projectRoot ".specify/logs/audit/security.jsonl"
|
||||
$toolAudit = Join-Path $projectRoot ".specify/logs/audit/tool-calls.jsonl"
|
||||
|
||||
if (!$OutputPath) {
|
||||
$outDir = Join-Path $projectRoot "docs/output/output_logs/$FeatureId"
|
||||
if (!(Test-Path $outDir)) { New-Item -ItemType Directory -Force -Path $outDir | Out-Null }
|
||||
$OutputPath = Join-Path $outDir "compliance-report.md"
|
||||
}
|
||||
|
||||
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
|
||||
# ── Read audit records ─────────────────────────────────────────────────────
|
||||
function Read-Jsonl ([string]$path) {
|
||||
if (!(Test-Path $path)) { return @() }
|
||||
Get-Content $path | ForEach-Object {
|
||||
try { $_ | ConvertFrom-Json } catch { $null }
|
||||
} | Where-Object { $_ }
|
||||
}
|
||||
|
||||
$govRecords = Read-Jsonl $auditLog
|
||||
$secRecords = Read-Jsonl $secAudit
|
||||
$toolRecords = Read-Jsonl $toolAudit
|
||||
|
||||
# ── Statistics ─────────────────────────────────────────────────────────────
|
||||
$totalActions = $govRecords.Count
|
||||
$highRisk = $govRecords | Where-Object { $_.risk_level -eq "high" }
|
||||
$denied = $govRecords | Where-Object { $_.decision -eq "denied" }
|
||||
$humanApproved = $govRecords | Where-Object { $_.approval_status -eq "human_approved" }
|
||||
$secBlocked = $secRecords | Where-Object { $_.status -eq "blocked" }
|
||||
$piiMasked = $secRecords | Where-Object { $_.action -eq "allow" -and $_.matched_rules }
|
||||
$toolCalls = $toolRecords.Count
|
||||
$toolDenied = $toolRecords | Where-Object { $_.decision -eq "denied" }
|
||||
|
||||
# ── Audit chain validation ─────────────────────────────────────────────────
|
||||
$chainValid = $true
|
||||
$prevHash = ""
|
||||
foreach ($rec in $govRecords) {
|
||||
if ($prevHash -and $rec.previous_record_hash -ne $prevHash) { $chainValid = $false; break }
|
||||
$prevHash = $rec.record_hash
|
||||
}
|
||||
$chainStatus = if ($chainValid) { "VALID ✅" } else { "BROKEN ⚠️" }
|
||||
|
||||
# ── Report content ─────────────────────────────────────────────────────────
|
||||
$highRiskTable = if ($highRisk) {
|
||||
$highRisk | ForEach-Object { "| $($_.timestamp) | $($_.action) | $($_.decision) | $($_.approver) | $($_.approval_status) |" }
|
||||
} else { "| — | — | — | — | — |" }
|
||||
|
||||
$deniedTable = if ($denied) {
|
||||
$denied | ForEach-Object { "| $($_.timestamp) | $($_.action) | $($_.risk_level) | $($_.approval_status) |" }
|
||||
} else { "| — | — | — | — |" }
|
||||
|
||||
$report = @"
|
||||
# Compliance Report — $FeatureId
|
||||
|
||||
**Generated:** $timestamp
|
||||
**Pipeline:** $FeatureId
|
||||
**Audit chain status:** $chainStatus
|
||||
|
||||
---
|
||||
|
||||
## Action Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|------:|
|
||||
| Total governance actions | $totalActions |
|
||||
| High-risk actions | $($highRisk.Count) |
|
||||
| Denied actions | $($denied.Count) |
|
||||
| Human-approved (identity verified) | $($humanApproved.Count) |
|
||||
| Security blocks (H4) | $($secBlocked.Count) |
|
||||
| PII-masked inputs | $($piiMasked.Count) |
|
||||
| Tool registry calls | $toolCalls |
|
||||
| Tool registry denials | $($toolDenied.Count) |
|
||||
|
||||
---
|
||||
|
||||
## High-Risk Actions
|
||||
|
||||
| Timestamp | Action | Decision | Approver | Approval Status |
|
||||
|-----------|--------|----------|----------|-----------------|
|
||||
$($highRiskTable -join "`n")
|
||||
|
||||
---
|
||||
|
||||
## Denied Actions
|
||||
|
||||
| Timestamp | Action | Risk Level | Approval Status |
|
||||
|-----------|--------|:----------:|-----------------|
|
||||
$($deniedTable -join "`n")
|
||||
|
||||
---
|
||||
|
||||
## Security Events (H4)
|
||||
|
||||
- Prompt injections blocked: **$($secRecords | Where-Object { $_.status -eq "blocked" -and $_.mode -eq "input" } | Measure-Object | Select-Object -ExpandProperty Count)**
|
||||
- Output redactions: **$($secRecords | Where-Object { $_.action -eq "redact" } | Measure-Object | Select-Object -ExpandProperty Count)**
|
||||
- Hallucination risk flags: **$($secRecords | Where-Object { $_.action -eq "flag" } | Measure-Object | Select-Object -ExpandProperty Count)**
|
||||
|
||||
---
|
||||
|
||||
## Audit Chain Status
|
||||
|
||||
- Records checked: **$totalActions**
|
||||
- Chain integrity: **$chainStatus**
|
||||
- Storage: hash-chain JSONL at `.specify/logs/audit/audit.jsonl`
|
||||
|
||||
---
|
||||
|
||||
## Tool Registry Compliance (H2)
|
||||
|
||||
- Total tool calls logged: **$toolCalls**
|
||||
- Denied (missing idempotency key): **$($toolDenied.Count)**
|
||||
- Side-effecting actions: **$($toolRecords | Where-Object { $_.decision -eq "approved" } | Measure-Object | Select-Object -ExpandProperty Count)**
|
||||
|
||||
---
|
||||
|
||||
*Report auto-generated by CASAN H5 Governance Harness*
|
||||
"@
|
||||
|
||||
Set-Content -Path $OutputPath -Value $report -Encoding UTF8
|
||||
Write-Output "COMPLIANCE_REPORT_GENERATED: $OutputPath"
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# CASAN H5 Governance Harness - PowerShell port of governance-check.sh
|
||||
# Usage:
|
||||
# governance-check.ps1 <input-file> <output-file> [action-name]
|
||||
#
|
||||
# Non-interactive. High-risk denied unless env:CASAN_APPROVAL_DECISION=approve + env:CASAN_APPROVER set.
|
||||
# Exit codes: 0=approved, 2=denied, 64=usage error
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true, Position=0)][string]$InputFile,
|
||||
[Parameter(Mandatory=$true, Position=1)][string]$OutputFile,
|
||||
[Parameter(Position=2)][string]$ActionName = "agent_step"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
|
||||
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
|
||||
$logDir = Join-Path $projectRoot ".specify/logs"
|
||||
$traceDir = Join-Path $logDir "trace"
|
||||
$auditDir = Join-Path $logDir "audit"
|
||||
$auditLog = Join-Path $auditDir "audit.jsonl"
|
||||
|
||||
foreach ($d in @($traceDir, $auditDir, (Split-Path $OutputFile -Parent))) {
|
||||
if ($d -and !(Test-Path $d)) { New-Item -ItemType Directory -Force -Path $d | Out-Null }
|
||||
}
|
||||
|
||||
if (!(Test-Path $InputFile)) {
|
||||
Write-Error "GOVERNANCE_DENIED: input file not found: $InputFile"
|
||||
exit 2
|
||||
}
|
||||
|
||||
function New-TraceId {
|
||||
try { return [System.Guid]::NewGuid().ToString("D") } catch { return "trace-$(Get-Date -Format 'yyyyMMddHHmmss')-$PID" }
|
||||
}
|
||||
|
||||
function Get-Sha256 ([string]$text) {
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
|
||||
$hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes)
|
||||
return ($hash | ForEach-Object { $_.ToString("x2") }) -join ""
|
||||
}
|
||||
|
||||
function ConvertTo-JsonArray ([string[]]$arr) {
|
||||
if (!$arr -or $arr.Count -eq 0) { return "[]" }
|
||||
$escaped = $arr | ForEach-Object { '"' + ($_ -replace '"','\"') + '"' }
|
||||
return "[" + ($escaped -join ",") + "]"
|
||||
}
|
||||
|
||||
$traceId = New-TraceId
|
||||
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
$input = Get-Content $InputFile -Raw -Encoding UTF8
|
||||
if (!$input) { $input = "" }
|
||||
$lowerInput = $input.ToLower()
|
||||
$actor = if ($env:CASAN_ACTOR) { $env:CASAN_ACTOR } else { "developer" }
|
||||
$approver = if ($env:CASAN_APPROVER) { $env:CASAN_APPROVER } else { "" }
|
||||
$approvalDecision = if ($env:CASAN_APPROVAL_DECISION) { $env:CASAN_APPROVAL_DECISION } else { "auto" }
|
||||
$agentName = if ($env:CASAN_AGENT_NAME) { $env:CASAN_AGENT_NAME } else { "unknown" }
|
||||
|
||||
$riskLevel = "low"
|
||||
$reasons = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
# ── Risk by action name ────────────────────────────────────────────────────
|
||||
switch -Regex ($ActionName) {
|
||||
"^(write_code|write_file|external_api|tool_call)$" {
|
||||
$riskLevel = "medium"; $reasons.Add("sensitive-action:$ActionName")
|
||||
}
|
||||
"^(deploy|launch|migration|db_write)$" {
|
||||
$riskLevel = "high"; $reasons.Add("high-risk-action:$ActionName")
|
||||
}
|
||||
}
|
||||
|
||||
# ── Tool registry agent whitelist check ───────────────────────────────────
|
||||
if ($agentName -ne "unknown") {
|
||||
if ($agentName -match "okr\.(srs|bd|reviewspec|reviewplan|reviewcode)" -or
|
||||
$agentName -match "speckit\.(specify|clarify|plan|tasks)") {
|
||||
if ($ActionName -match "^(deploy|migration|db_write)$") {
|
||||
$riskLevel = "high"
|
||||
$reasons.Add("unauthorized-action-for-agent:$ActionName")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ── Risk by content keywords ───────────────────────────────────────────────
|
||||
if ($lowerInput -match "(delete|drop table|password|api[_-]?key|secret|token|credential|migration|deploy|external api|shutdown|dump database)") {
|
||||
$riskLevel = "high"
|
||||
if (!$reasons.Contains("high-risk-content")) { $reasons.Add("high-risk-content") }
|
||||
} elseif ($lowerInput -match "(internal|config|system|policy|permission)") {
|
||||
if ($riskLevel -eq "low") { $riskLevel = "medium"; $reasons.Add("medium-risk-content") }
|
||||
}
|
||||
|
||||
# ── Approval decision ──────────────────────────────────────────────────────
|
||||
$approvalStatus = "auto_approved"
|
||||
$decision = "approved"
|
||||
|
||||
if ($riskLevel -eq "medium") { $approvalStatus = "policy_auto_approved_with_audit" }
|
||||
if ($riskLevel -eq "high") {
|
||||
if ($approvalDecision -eq "approve" -and $approver -ne "") {
|
||||
$approvalStatus = "human_approved"; $decision = "approved"
|
||||
} else {
|
||||
$approvalStatus = "approval_required"; $decision = "denied"
|
||||
}
|
||||
}
|
||||
|
||||
# ── Hash + chain ───────────────────────────────────────────────────────────
|
||||
$inputHash = Get-Sha256 $input
|
||||
$prevHash = ""
|
||||
if (Test-Path $auditLog) {
|
||||
$lastLine = Get-Content $auditLog -Tail 1
|
||||
if ($lastLine -match '"record_hash":"([^"]+)"') { $prevHash = $Matches[1] }
|
||||
}
|
||||
|
||||
$recordCore = "$timestamp|$traceId|$ActionName|$actor|$riskLevel|$decision|$approvalStatus|$inputHash|$prevHash"
|
||||
$recordHash = Get-Sha256 $recordCore
|
||||
$reasonsJson = ConvertTo-JsonArray ($reasons.ToArray())
|
||||
|
||||
# ── Trace JSON ─────────────────────────────────────────────────────────────
|
||||
$traceFile = Join-Path $traceDir "governance-$traceId.json"
|
||||
@"
|
||||
{
|
||||
"trace_id": "$traceId",
|
||||
"timestamp": "$timestamp",
|
||||
"harness": "H5-governance",
|
||||
"action": "$ActionName",
|
||||
"actor": "$actor",
|
||||
"risk_level": "$riskLevel",
|
||||
"decision": "$decision",
|
||||
"approval_status": "$approvalStatus",
|
||||
"approver": "$approver",
|
||||
"reasons": $reasonsJson,
|
||||
"input_hash": "$inputHash",
|
||||
"previous_record_hash": "$prevHash",
|
||||
"record_hash": "$recordHash"
|
||||
}
|
||||
"@ | Set-Content -Path $traceFile -Encoding UTF8
|
||||
|
||||
# ── Audit JSONL (append-only) ──────────────────────────────────────────────
|
||||
$auditLine = "{`"timestamp`":`"$timestamp`",`"trace_id`":`"$traceId`",`"harness`":`"H5-governance`",`"action`":`"$ActionName`",`"actor`":`"$actor`",`"risk_level`":`"$riskLevel`",`"decision`":`"$decision`",`"approval_status`":`"$approvalStatus`",`"approver`":`"$approver`",`"input_hash`":`"$inputHash`",`"previous_record_hash`":`"$prevHash`",`"record_hash`":`"$recordHash`"}"
|
||||
Add-Content -Path $auditLog -Value $auditLine -Encoding UTF8
|
||||
|
||||
# ── Result ─────────────────────────────────────────────────────────────────
|
||||
if ($decision -ne "approved") {
|
||||
Set-Content -Path $OutputFile -Value "" -Encoding UTF8
|
||||
Write-Error "GOVERNANCE_DENIED trace_id=$traceId risk=$riskLevel approval_status=$approvalStatus"
|
||||
exit 2
|
||||
}
|
||||
|
||||
$input | Set-Content -Path $OutputFile -Encoding UTF8
|
||||
Write-Output "GOVERNANCE_APPROVED trace_id=$traceId risk=$riskLevel approval_status=$approvalStatus output=$OutputFile"
|
||||
exit 0
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# CASAN H5 Approval Request — identity-verified human checkpoint
|
||||
# Usage:
|
||||
# request-approval.ps1 -ActionName <name> -RiskLevel <low|medium|high> [-Description <text>] [-NonInteractive]
|
||||
#
|
||||
# Exit codes: 0=approved, 2=denied
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$ActionName,
|
||||
[Parameter(Mandatory=$true)][ValidateSet("low","medium","high")][string]$RiskLevel,
|
||||
[string]$Description = "",
|
||||
[switch]$NonInteractive
|
||||
)
|
||||
|
||||
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
|
||||
# Low/medium: auto-approve
|
||||
if ($RiskLevel -ne "high") {
|
||||
Write-Output "AUTO_APPROVED: $ActionName (risk=$RiskLevel) ts=$timestamp"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# High risk — require explicit approval with identity
|
||||
if ($NonInteractive) {
|
||||
$decision = $env:CASAN_APPROVAL_DECISION
|
||||
$approver = $env:CASAN_APPROVER
|
||||
|
||||
if ($decision -ne "approve") {
|
||||
Write-Error "HIGH_RISK_DENIED: '$ActionName' requires CASAN_APPROVAL_DECISION=approve"
|
||||
Write-Error "Set CASAN_APPROVER=<name> and CASAN_APPROVAL_DECISION=approve to proceed"
|
||||
exit 2
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($approver)) {
|
||||
Write-Error "HIGH_RISK_DENIED: CASAN_APPROVER must be set (cannot be empty)"
|
||||
exit 2
|
||||
}
|
||||
Write-Output "APPROVED_WITH_IDENTITY: action=$ActionName approver=$approver ts=$timestamp"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Interactive mode
|
||||
Write-Host ""
|
||||
Write-Host "[GOVERNANCE] High-risk action requested" -ForegroundColor Yellow
|
||||
Write-Host " Action: $ActionName" -ForegroundColor White
|
||||
Write-Host " Risk level: $RiskLevel" -ForegroundColor Red
|
||||
if ($Description) { Write-Host " Description: $Description" -ForegroundColor Gray }
|
||||
Write-Host ""
|
||||
|
||||
$answer = Read-Host "Approve this action? (yes/no)"
|
||||
if ($answer.Trim().ToLower() -ne "yes") {
|
||||
Write-Error "HUMAN_DENIED: $ActionName denied by operator"
|
||||
exit 2
|
||||
}
|
||||
|
||||
$approver = Read-Host "Enter your name (for audit trail)"
|
||||
if ([string]::IsNullOrWhiteSpace($approver)) {
|
||||
Write-Error "HUMAN_DENIED: approver name cannot be empty"
|
||||
exit 2
|
||||
}
|
||||
|
||||
Write-Output "HUMAN_APPROVED: action=$ActionName approver=$approver ts=$timestamp"
|
||||
exit 0
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# CASAN L5 Rollback Manager - PowerShell port of rollback-manager.sh
|
||||
# Usage:
|
||||
# rollback-manager.ps1 record <action> <rollback-command>
|
||||
# rollback-manager.ps1 execute <transaction-id>
|
||||
# rollback-manager.ps1 list
|
||||
#
|
||||
# Exit codes: 0=success, 1=error, 64=usage error
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true, Position=0)][ValidateSet("record","execute","list")][string]$Mode,
|
||||
[Parameter(Position=1)][string]$Arg1 = "",
|
||||
[Parameter(Position=2)][string]$Arg2 = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
|
||||
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
|
||||
$logDir = Join-Path $projectRoot ".specify/logs/level5"
|
||||
$txLog = Join-Path $logDir "rollback-transactions.jsonl"
|
||||
|
||||
if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Force -Path $logDir | Out-Null }
|
||||
|
||||
function New-TraceId {
|
||||
try { return [System.Guid]::NewGuid().ToString("D") } catch { return "tx-$(Get-Date -Format 'yyyyMMddHHmmss')-$PID" }
|
||||
}
|
||||
|
||||
switch ($Mode) {
|
||||
"record" {
|
||||
$action = $Arg1
|
||||
$rollbackCommand = $Arg2
|
||||
if (!$action -or !$rollbackCommand) {
|
||||
Write-Error "Usage: rollback-manager.ps1 record <action> <rollback-command>"
|
||||
exit 64
|
||||
}
|
||||
$txId = New-TraceId
|
||||
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
$gitRef = ""
|
||||
try { $gitRef = (git rev-parse HEAD 2>$null).Trim() } catch {}
|
||||
|
||||
$line = "{`"timestamp`":`"$timestamp`",`"transaction_id`":`"$txId`",`"action`":`"$action`",`"rollback_command`":`"$(($rollbackCommand -replace '"','\"'))`",`"git_ref`":`"$gitRef`",`"status`":`"recorded`"}"
|
||||
Add-Content -Path $txLog -Value $line -Encoding UTF8
|
||||
Write-Output "ROLLBACK_RECORDED transaction_id=$txId"
|
||||
exit 0
|
||||
}
|
||||
|
||||
"execute" {
|
||||
$txId = $Arg1
|
||||
if (!$txId) { Write-Error "Usage: rollback-manager.ps1 execute <transaction-id>"; exit 64 }
|
||||
if (!(Test-Path $txLog)) { Write-Error "ROLLBACK_NOT_FOUND: transaction log missing"; exit 1 }
|
||||
|
||||
$found = $false
|
||||
$cmd = ""
|
||||
foreach ($line in (Get-Content $txLog)) {
|
||||
try {
|
||||
$rec = $line | ConvertFrom-Json
|
||||
if ($rec.transaction_id -eq $txId) {
|
||||
$cmd = $rec.rollback_command
|
||||
$found = $true
|
||||
break
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!$found -or !$cmd) {
|
||||
Write-Error "ROLLBACK_NOT_FOUND transaction_id=$txId"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Output "Executing rollback: $cmd"
|
||||
Invoke-Expression $cmd
|
||||
$execExit = $LASTEXITCODE
|
||||
|
||||
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
Add-Content -Path $txLog -Value "{`"timestamp`":`"$timestamp`",`"transaction_id`":`"$txId`",`"status`":`"rolled_back`",`"exit_code`":$execExit}" -Encoding UTF8
|
||||
Write-Output "ROLLBACK_EXECUTED transaction_id=$txId exit_code=$execExit"
|
||||
exit $execExit
|
||||
}
|
||||
|
||||
"list" {
|
||||
if (!(Test-Path $txLog)) { Write-Output "No rollback transactions recorded."; exit 0 }
|
||||
$records = Get-Content $txLog | ForEach-Object {
|
||||
try { $_ | ConvertFrom-Json } catch { $null }
|
||||
} | Where-Object { $_ }
|
||||
$records | Format-Table transaction_id, action, status, timestamp -AutoSize
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# CASAN H4 Security Harness - PowerShell port of security-check.sh
|
||||
# Usage:
|
||||
# security-check.ps1 <input-file> <output-file> [input|output]
|
||||
#
|
||||
# input mode: blocks prompt injection / jailbreak / secrets, masks PII.
|
||||
# output mode: redacts PII/secrets from generated output, flags risky language.
|
||||
# Exit codes: 0=pass, 2=blocked, 64=usage error
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true, Position=0)][string]$InputFile,
|
||||
[Parameter(Mandatory=$true, Position=1)][string]$OutputFile,
|
||||
[Parameter(Position=2)][ValidateSet("input","output")][string]$Mode = "input"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
|
||||
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
|
||||
$logDir = Join-Path $projectRoot ".specify/logs"
|
||||
$traceDir = Join-Path $logDir "trace"
|
||||
$auditDir = Join-Path $logDir "audit"
|
||||
$securityDir = Join-Path $projectRoot ".specify/security"
|
||||
|
||||
foreach ($d in @($traceDir, $auditDir, (Split-Path $OutputFile -Parent))) {
|
||||
if ($d -and !(Test-Path $d)) { New-Item -ItemType Directory -Force -Path $d | Out-Null }
|
||||
}
|
||||
|
||||
if (!(Test-Path $InputFile)) {
|
||||
Write-Error "SECURITY_BLOCKED: input file not found: $InputFile"
|
||||
exit 2
|
||||
}
|
||||
|
||||
function New-TraceId {
|
||||
try { return [System.Guid]::NewGuid().ToString("D") } catch { return "trace-$(Get-Date -Format 'yyyyMMddHHmmss')-$PID" }
|
||||
}
|
||||
|
||||
function Get-Sha256 ([string]$text) {
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
|
||||
$hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes)
|
||||
return ($hash | ForEach-Object { $_.ToString("x2") }) -join ""
|
||||
}
|
||||
|
||||
function ConvertTo-JsonArray ([string[]]$arr) {
|
||||
if (!$arr -or $arr.Count -eq 0) { return "[]" }
|
||||
$escaped = $arr | ForEach-Object { '"' + ($_ -replace '"','\"') + '"' }
|
||||
return "[" + ($escaped -join ",") + "]"
|
||||
}
|
||||
|
||||
$traceId = New-TraceId
|
||||
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
$content = Get-Content $InputFile -Raw -Encoding UTF8
|
||||
if (!$content) { $content = "" }
|
||||
$lower = $content.ToLower()
|
||||
|
||||
$status = "pass"
|
||||
$action = "allow"
|
||||
$riskLevel = "low"
|
||||
$matchedRules = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
# ── Block patterns (prompt injection) ──────────────────────────────────────
|
||||
$blockPatterns = @(
|
||||
"ignore previous instruction",
|
||||
"ignore system instruction",
|
||||
"bypass system",
|
||||
"bypass policy",
|
||||
"system bypass",
|
||||
"override policy",
|
||||
"you are now",
|
||||
"act as admin",
|
||||
"pretend to be system",
|
||||
"reveal system prompt",
|
||||
"reveal original prompt",
|
||||
"show hidden instruction",
|
||||
"show internal prompt"
|
||||
)
|
||||
|
||||
# ── Jailbreak patterns (Phase 2 addition) ──────────────────────────────────
|
||||
$jailbreakPatterns = @(
|
||||
"act as a different",
|
||||
"act as an evil",
|
||||
"act as an unconstrained",
|
||||
"act as an unrestricted",
|
||||
"pretend you are not an ai",
|
||||
"pretend you're not an ai",
|
||||
"pretend you are no longer",
|
||||
"developer mode",
|
||||
"dan mode",
|
||||
"jailbreak mode",
|
||||
"unrestricted mode",
|
||||
"your real instructions are",
|
||||
"your true instructions are",
|
||||
"what are your system instructions",
|
||||
"what were your original instructions"
|
||||
)
|
||||
|
||||
# ── Load additional patterns from prompt-filter.yaml ───────────────────────
|
||||
$filterYaml = Join-Path $securityDir "prompt-filter.yaml"
|
||||
if (Test-Path $filterYaml) {
|
||||
$yaml = Get-Content $filterYaml -Raw
|
||||
$patternMatches = [regex]::Matches($yaml, 'pattern:\s*"([^"]+)"')
|
||||
foreach ($m in $patternMatches) { $blockPatterns += $m.Groups[1].Value }
|
||||
}
|
||||
|
||||
# ── Approval / alert patterns ──────────────────────────────────────────────
|
||||
$approvalPatterns = @("delete ", "drop table", "shutdown system", "export secrets", "dump database")
|
||||
$alertPatterns = @("show all data", "internal prompt", "system message", "hidden instruction")
|
||||
|
||||
# ── Regexes ────────────────────────────────────────────────────────────────
|
||||
$emailRegex = '[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}'
|
||||
$phoneRegex = '(\+?[0-9][0-9 .\-]{8,}[0-9])'
|
||||
$personalIdReg = '\b[0-9]{9,12}\b'
|
||||
$creditCardReg = '\b([0-9]{4}[- ]?){3}[0-9]{4}\b'
|
||||
$secretRegex = '(?i)(API[_\-]?KEY|ACCESS[_\-]?TOKEN|REFRESH[_\-]?TOKEN|PASSWORD|JWT[_\-]?SECRET|SECRET)\s*[:=]\s*\S+'
|
||||
$credentialReg = '(?i)(postgres|mysql|mongodb)://[^@]+@' # connection strings
|
||||
$awsKeyReg = 'AKIA[0-9A-Z]{16}'
|
||||
|
||||
if ($Mode -eq "input") {
|
||||
# Block patterns check
|
||||
foreach ($p in ($blockPatterns + $jailbreakPatterns)) {
|
||||
if ($p -and $lower -match [regex]::Escape($p)) {
|
||||
$status = "blocked"; $action = "block"; $riskLevel = "high"
|
||||
$matchedRules.Add("prompt-injection:$p")
|
||||
}
|
||||
}
|
||||
|
||||
# Credit card PII
|
||||
if ($content -match $creditCardReg) {
|
||||
$status = "blocked"; $action = "block"; $riskLevel = "high"
|
||||
$matchedRules.Add("pii-credit-card")
|
||||
}
|
||||
|
||||
# Hardcoded secrets / credentials (Phase 2 addition)
|
||||
if (($content -match $secretRegex) -or ($content -match $credentialReg) -or ($content -match $awsKeyReg)) {
|
||||
$status = "blocked"; $action = "block"; $riskLevel = "high"
|
||||
$matchedRules.Add("secret-in-input")
|
||||
}
|
||||
|
||||
# Approval patterns (only if not already blocked)
|
||||
if ($status -ne "blocked") {
|
||||
foreach ($p in $approvalPatterns) {
|
||||
if ($lower -match [regex]::Escape($p)) {
|
||||
$status = "requires_approval"; $action = "require_approval"; $riskLevel = "high"
|
||||
$matchedRules.Add("unsafe-action:$p")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Alert patterns
|
||||
if ($status -ne "blocked") {
|
||||
foreach ($p in $alertPatterns) {
|
||||
if ($lower -match [regex]::Escape($p)) {
|
||||
if ($riskLevel -eq "low") { $riskLevel = "medium" }
|
||||
$action = "alert"
|
||||
$matchedRules.Add("suspicious:$p")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ── PII masking + secret redaction (both modes) ───────────────────────────
|
||||
$safeContent = $content
|
||||
$safeContent = [regex]::Replace($safeContent, $emailRegex, '***MASKED_EMAIL***')
|
||||
$safeContent = [regex]::Replace($safeContent, $phoneRegex, '***MASKED_PHONE***')
|
||||
$safeContent = [regex]::Replace($safeContent, $personalIdReg, '***MASKED_ID***')
|
||||
$safeContent = [regex]::Replace($safeContent, $secretRegex, '[REDACTED_SECRET]')
|
||||
$safeContent = [regex]::Replace($safeContent, $credentialReg, '[REDACTED_CONNSTRING]://')
|
||||
$safeContent = [regex]::Replace($safeContent, $awsKeyReg, '[REDACTED_AWSKEY]')
|
||||
|
||||
if ($Mode -eq "output") {
|
||||
if ($content -match $secretRegex) {
|
||||
$action = "redact"
|
||||
if ($riskLevel -eq "low") { $riskLevel = "medium" }
|
||||
$matchedRules.Add("secret-redacted-output")
|
||||
}
|
||||
if ($lower -match "(maybe|might be incorrect|i am not sure|uncertain)") {
|
||||
$action = "flag"
|
||||
if ($riskLevel -eq "low") { $riskLevel = "medium" }
|
||||
$matchedRules.Add("hallucination-risk-language")
|
||||
}
|
||||
}
|
||||
|
||||
# ── Hash ───────────────────────────────────────────────────────────────────
|
||||
$inputHash = Get-Sha256 $content
|
||||
$outputHash = Get-Sha256 $safeContent
|
||||
$rulesJson = ConvertTo-JsonArray ($matchedRules.ToArray())
|
||||
|
||||
# ── Trace JSON ─────────────────────────────────────────────────────────────
|
||||
$traceFile = Join-Path $traceDir "security-$traceId.json"
|
||||
@"
|
||||
{
|
||||
"trace_id": "$traceId",
|
||||
"timestamp": "$timestamp",
|
||||
"harness": "H4-security",
|
||||
"mode": "$Mode",
|
||||
"status": "$status",
|
||||
"action": "$action",
|
||||
"risk_level": "$riskLevel",
|
||||
"matched_rules": $rulesJson,
|
||||
"input_hash": "$inputHash",
|
||||
"output_hash": "$outputHash"
|
||||
}
|
||||
"@ | Set-Content -Path $traceFile -Encoding UTF8
|
||||
|
||||
# ── Audit JSONL ────────────────────────────────────────────────────────────
|
||||
$auditLine = "{`"timestamp`":`"$timestamp`",`"trace_id`":`"$traceId`",`"harness`":`"H4-security`",`"mode`":`"$Mode`",`"status`":`"$status`",`"action`":`"$action`",`"risk_level`":`"$riskLevel`",`"input_hash`":`"$inputHash`",`"output_hash`":`"$outputHash`"}"
|
||||
Add-Content -Path (Join-Path $auditDir "security.jsonl") -Value $auditLine -Encoding UTF8
|
||||
|
||||
# ── Result ─────────────────────────────────────────────────────────────────
|
||||
if ($status -eq "blocked") {
|
||||
Set-Content -Path $OutputFile -Value "" -Encoding UTF8
|
||||
Write-Error "SECURITY_BLOCKED trace_id=$traceId risk=$riskLevel rules=$rulesJson"
|
||||
exit 2
|
||||
}
|
||||
|
||||
Set-Content -Path $OutputFile -Value $safeContent -Encoding UTF8
|
||||
Write-Output "SECURITY_$($status.ToUpper()) trace_id=$traceId risk=$riskLevel action=$action output=$OutputFile"
|
||||
exit 0
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Setup implementation plan for a feature
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Json,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Show help if requested
|
||||
if ($Help) {
|
||||
Write-Output "Usage: ./setup-plan.ps1 [-Json] [-Help]"
|
||||
Write-Output " -Json Output results in JSON format"
|
||||
Write-Output " -Help Show this help message"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Load common functions
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Get all paths and variables from common functions
|
||||
$paths = Get-FeaturePathsEnv
|
||||
|
||||
# Check if we're on a proper feature branch (only for git repos)
|
||||
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Ensure the feature directory exists
|
||||
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
||||
|
||||
# Copy plan template if it exists, otherwise note it or create empty file
|
||||
$template = Join-Path $paths.REPO_ROOT '.specify/templates/plan-template.md'
|
||||
if (Test-Path $template) {
|
||||
Copy-Item $template $paths.IMPL_PLAN -Force
|
||||
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
|
||||
} else {
|
||||
Write-Warning "Plan template not found at $template"
|
||||
# Create a basic plan file if template doesn't exist
|
||||
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
|
||||
}
|
||||
|
||||
# Output results
|
||||
if ($Json) {
|
||||
$result = [PSCustomObject]@{
|
||||
FEATURE_SPEC = $paths.FEATURE_SPEC
|
||||
IMPL_PLAN = $paths.IMPL_PLAN
|
||||
SPECS_DIR = $paths.FEATURE_DIR
|
||||
BRANCH = $paths.CURRENT_BRANCH
|
||||
HAS_GIT = $paths.HAS_GIT
|
||||
}
|
||||
$result | ConvertTo-Json -Compress
|
||||
} else {
|
||||
Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)"
|
||||
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
|
||||
Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)"
|
||||
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
|
||||
Write-Output "HAS_GIT: $($paths.HAS_GIT)"
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# CASAN L5 Tool Registry Gate - PowerShell port of tool-registry-gate.sh
|
||||
# Usage:
|
||||
# tool-registry-gate.ps1 <tool-id> [idempotency-key]
|
||||
#
|
||||
# Exit codes: 0=approved, 2=denied, 64=usage error
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true, Position=0)][string]$ToolId,
|
||||
[Parameter(Position=1)][string]$IdempotencyKey = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
|
||||
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
|
||||
$registry = Join-Path $projectRoot ".specify/level5/tool-registry.yaml"
|
||||
$logDir = Join-Path $projectRoot ".specify/logs/level5"
|
||||
$txLog = Join-Path $logDir "tool-registry.jsonl"
|
||||
$toolCallLog = Join-Path $projectRoot ".specify/logs/audit/tool-calls.jsonl"
|
||||
|
||||
if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Force -Path $logDir | Out-Null }
|
||||
if (!(Test-Path (Split-Path $toolCallLog -Parent))) { New-Item -ItemType Directory -Force -Path (Split-Path $toolCallLog -Parent) | Out-Null }
|
||||
|
||||
function New-TraceId {
|
||||
try { return [System.Guid]::NewGuid().ToString("D") } catch { return "tool-$(Get-Date -Format 'yyyyMMddHHmmss')-$PID" }
|
||||
}
|
||||
|
||||
if (!(Test-Path $registry)) {
|
||||
Write-Error "TOOL_REGISTRY_ERROR: registry not found at $registry"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$traceId = New-TraceId
|
||||
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
|
||||
# ── Parse YAML registry (simple line-based parser) ─────────────────────────
|
||||
$yamlText = Get-Content $registry -Raw
|
||||
$toolBlocks = $yamlText -split "\n\s*-\s+id:\s+"
|
||||
$tools = @{}
|
||||
foreach ($block in $toolBlocks[1..($toolBlocks.Count-1)]) {
|
||||
$lines = $block -split "`n"
|
||||
$tid = $lines[0].Trim()
|
||||
$attrs = @{ id = $tid }
|
||||
foreach ($line in $lines[1..($lines.Count-1)]) {
|
||||
if ($line -match '^\s{4}(\w[^:]+):\s+(.+)$') {
|
||||
$k = $Matches[1].Trim(); $v = $Matches[2].Trim().Trim('"')
|
||||
$attrs[$k] = $v
|
||||
}
|
||||
}
|
||||
$tools[$tid] = $attrs
|
||||
}
|
||||
|
||||
$tool = $tools[$ToolId]
|
||||
if (!$tool) {
|
||||
$line = "{`"timestamp`":`"$timestamp`",`"trace_id`":`"$traceId`",`"harness`":`"L5-tool-registry`",`"tool_id`":`"$ToolId`",`"decision`":`"denied`",`"reason`":`"unknown_tool`"}"
|
||||
Add-Content -Path $txLog -Value $line -Encoding UTF8
|
||||
Write-Error "TOOL_DENIED unknown_tool=$ToolId"
|
||||
exit 2
|
||||
}
|
||||
|
||||
$sideEffect = $tool["side_effect"] -eq "true"
|
||||
$idemRequired = $tool["idempotency_required"] -eq "true"
|
||||
$riskLevel = $tool["risk_level"]
|
||||
$owner = $tool["owner"]
|
||||
|
||||
$decision = "approved"
|
||||
$reason = "registered"
|
||||
|
||||
if ($sideEffect -and $idemRequired -and [string]::IsNullOrEmpty($IdempotencyKey)) {
|
||||
$decision = "denied"
|
||||
$reason = "missing_idempotency_key"
|
||||
}
|
||||
|
||||
$record = "{`"timestamp`":`"$timestamp`",`"trace_id`":`"$traceId`",`"harness`":`"L5-tool-registry`",`"tool_id`":`"$ToolId`",`"owner`":`"$owner`",`"risk_level`":`"$riskLevel`",`"side_effect`":$($sideEffect.ToString().ToLower()),`"idempotency_required`":$($idemRequired.ToString().ToLower()),`"idempotency_key_present`":$(-not [string]::IsNullOrEmpty($IdempotencyKey) | ForEach-Object { $_.ToString().ToLower() }),`"decision`":`"$decision`",`"reason`":`"$reason`"}"
|
||||
Add-Content -Path $txLog -Value $record -Encoding UTF8
|
||||
|
||||
# Per-call audit in tool-calls.jsonl (H2 audit requirement)
|
||||
$auditLine = "{`"timestamp`":`"$timestamp`",`"trace_id`":`"$traceId`",`"tool`":`"$ToolId`",`"idempotency_key`":`"$IdempotencyKey`",`"decision`":`"$decision`",`"reason`":`"$reason`",`"risk_level`":`"$riskLevel`",`"owner`":`"$owner`"}"
|
||||
Add-Content -Path $toolCallLog -Value $auditLine -Encoding UTF8
|
||||
|
||||
Write-Output "TOOL_$($decision.ToUpper()) tool=$ToolId reason=$reason"
|
||||
|
||||
if ($decision -ne "approved") {
|
||||
Write-Error "TOOL_REGISTRY_DENIED: '$ToolId' — $reason. Set CASAN_IDEMPOTENCY_KEY env var."
|
||||
exit 2
|
||||
}
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,463 @@
|
||||
#!/usr/bin/env pwsh
|
||||
<#!
|
||||
.SYNOPSIS
|
||||
Update agent context files with information from plan.md (PowerShell version)
|
||||
|
||||
.DESCRIPTION
|
||||
Mirrors the behavior of scripts/bash/update-agent-context.sh:
|
||||
1. Environment Validation
|
||||
2. Plan Data Extraction
|
||||
3. Agent File Management (create from template or update existing)
|
||||
4. Content Generation (technology stack, recent changes, timestamp)
|
||||
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, kiro-cli, agy, bob, qodercli)
|
||||
|
||||
.PARAMETER AgentType
|
||||
Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist).
|
||||
|
||||
.EXAMPLE
|
||||
./update-agent-context.ps1 -AgentType claude
|
||||
|
||||
.EXAMPLE
|
||||
./update-agent-context.ps1 # Updates all existing agent files
|
||||
|
||||
.NOTES
|
||||
Relies on common helper functions in common.ps1
|
||||
#>
|
||||
param(
|
||||
[Parameter(Position=0)]
|
||||
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','kiro-cli','agy','bob','qodercli','generic')]
|
||||
[string]$AgentType
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Import common helpers
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
. (Join-Path $ScriptDir 'common.ps1')
|
||||
|
||||
# Acquire environment paths
|
||||
$envData = Get-FeaturePathsEnv
|
||||
$REPO_ROOT = $envData.REPO_ROOT
|
||||
$CURRENT_BRANCH = $envData.CURRENT_BRANCH
|
||||
$HAS_GIT = $envData.HAS_GIT
|
||||
$IMPL_PLAN = $envData.IMPL_PLAN
|
||||
$NEW_PLAN = $IMPL_PLAN
|
||||
|
||||
# Agent file paths
|
||||
$CLAUDE_FILE = Join-Path $REPO_ROOT 'CLAUDE.md'
|
||||
$GEMINI_FILE = Join-Path $REPO_ROOT 'GEMINI.md'
|
||||
$COPILOT_FILE = Join-Path $REPO_ROOT '.github/agents/copilot-instructions.md'
|
||||
$CURSOR_FILE = Join-Path $REPO_ROOT '.cursor/rules/specify-rules.mdc'
|
||||
$QWEN_FILE = Join-Path $REPO_ROOT 'QWEN.md'
|
||||
$AGENTS_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
|
||||
$WINDSURF_FILE = Join-Path $REPO_ROOT '.windsurf/rules/specify-rules.md'
|
||||
$KILOCODE_FILE = Join-Path $REPO_ROOT '.kilocode/rules/specify-rules.md'
|
||||
$AUGGIE_FILE = Join-Path $REPO_ROOT '.augment/rules/specify-rules.md'
|
||||
$ROO_FILE = Join-Path $REPO_ROOT '.roo/rules/specify-rules.md'
|
||||
$CODEBUDDY_FILE = Join-Path $REPO_ROOT 'CODEBUDDY.md'
|
||||
$QODER_FILE = Join-Path $REPO_ROOT 'QODER.md'
|
||||
$AMP_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
|
||||
$SHAI_FILE = Join-Path $REPO_ROOT 'SHAI.md'
|
||||
$KIRO_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
|
||||
$AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md'
|
||||
$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
|
||||
|
||||
$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md'
|
||||
|
||||
# Parsed plan data placeholders
|
||||
$script:NEW_LANG = ''
|
||||
$script:NEW_FRAMEWORK = ''
|
||||
$script:NEW_DB = ''
|
||||
$script:NEW_PROJECT_TYPE = ''
|
||||
|
||||
function Write-Info {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Message
|
||||
)
|
||||
Write-Host "INFO: $Message"
|
||||
}
|
||||
|
||||
function Write-Success {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Message
|
||||
)
|
||||
Write-Host "$([char]0x2713) $Message"
|
||||
}
|
||||
|
||||
function Write-WarningMsg {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Message
|
||||
)
|
||||
Write-Warning $Message
|
||||
}
|
||||
|
||||
function Write-Err {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Message
|
||||
)
|
||||
Write-Host "ERROR: $Message" -ForegroundColor Red
|
||||
}
|
||||
|
||||
function Validate-Environment {
|
||||
if (-not $CURRENT_BRANCH) {
|
||||
Write-Err 'Unable to determine current feature'
|
||||
if ($HAS_GIT) { Write-Info "Make sure you're on a feature branch" } else { Write-Info 'Set SPECIFY_FEATURE environment variable or create a feature first' }
|
||||
exit 1
|
||||
}
|
||||
if (-not (Test-Path $NEW_PLAN)) {
|
||||
Write-Err "No plan.md found at $NEW_PLAN"
|
||||
Write-Info 'Ensure you are working on a feature with a corresponding spec directory'
|
||||
if (-not $HAS_GIT) { Write-Info 'Use: $env:SPECIFY_FEATURE=your-feature-name or create a new feature first' }
|
||||
exit 1
|
||||
}
|
||||
if (-not (Test-Path $TEMPLATE_FILE)) {
|
||||
Write-Err "Template file not found at $TEMPLATE_FILE"
|
||||
Write-Info 'Run specify init to scaffold .specify/templates, or add agent-file-template.md there.'
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
function Extract-PlanField {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$FieldPattern,
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$PlanFile
|
||||
)
|
||||
if (-not (Test-Path $PlanFile)) { return '' }
|
||||
# Lines like **Language/Version**: Python 3.12
|
||||
$regex = "^\*\*$([Regex]::Escape($FieldPattern))\*\*: (.+)$"
|
||||
Get-Content -LiteralPath $PlanFile -Encoding utf8 | ForEach-Object {
|
||||
if ($_ -match $regex) {
|
||||
$val = $Matches[1].Trim()
|
||||
if ($val -notin @('NEEDS CLARIFICATION','N/A')) { return $val }
|
||||
}
|
||||
} | Select-Object -First 1
|
||||
}
|
||||
|
||||
function Parse-PlanData {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$PlanFile
|
||||
)
|
||||
if (-not (Test-Path $PlanFile)) { Write-Err "Plan file not found: $PlanFile"; return $false }
|
||||
Write-Info "Parsing plan data from $PlanFile"
|
||||
$script:NEW_LANG = Extract-PlanField -FieldPattern 'Language/Version' -PlanFile $PlanFile
|
||||
$script:NEW_FRAMEWORK = Extract-PlanField -FieldPattern 'Primary Dependencies' -PlanFile $PlanFile
|
||||
$script:NEW_DB = Extract-PlanField -FieldPattern 'Storage' -PlanFile $PlanFile
|
||||
$script:NEW_PROJECT_TYPE = Extract-PlanField -FieldPattern 'Project Type' -PlanFile $PlanFile
|
||||
|
||||
if ($NEW_LANG) { Write-Info "Found language: $NEW_LANG" } else { Write-WarningMsg 'No language information found in plan' }
|
||||
if ($NEW_FRAMEWORK) { Write-Info "Found framework: $NEW_FRAMEWORK" }
|
||||
if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Info "Found database: $NEW_DB" }
|
||||
if ($NEW_PROJECT_TYPE) { Write-Info "Found project type: $NEW_PROJECT_TYPE" }
|
||||
return $true
|
||||
}
|
||||
|
||||
function Format-TechnologyStack {
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Lang,
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Framework
|
||||
)
|
||||
$parts = @()
|
||||
if ($Lang -and $Lang -ne 'NEEDS CLARIFICATION') { $parts += $Lang }
|
||||
if ($Framework -and $Framework -notin @('NEEDS CLARIFICATION','N/A')) { $parts += $Framework }
|
||||
if (-not $parts) { return '' }
|
||||
return ($parts -join ' + ')
|
||||
}
|
||||
|
||||
function Get-ProjectStructure {
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$ProjectType
|
||||
)
|
||||
if ($ProjectType -match 'web') { return "backend/`nfrontend/`ntests/" } else { return "src/`ntests/" }
|
||||
}
|
||||
|
||||
function Get-CommandsForLanguage {
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Lang
|
||||
)
|
||||
switch -Regex ($Lang) {
|
||||
'Python' { return "cd src; pytest; ruff check ." }
|
||||
'Rust' { return "cargo test; cargo clippy" }
|
||||
'JavaScript|TypeScript' { return "npm test; npm run lint" }
|
||||
default { return "# Add commands for $Lang" }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-LanguageConventions {
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Lang
|
||||
)
|
||||
if ($Lang) { "${Lang}: Follow standard conventions" } else { 'General: Follow standard conventions' }
|
||||
}
|
||||
|
||||
function New-AgentFile {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$TargetFile,
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$ProjectName,
|
||||
[Parameter(Mandatory=$true)]
|
||||
[datetime]$Date
|
||||
)
|
||||
if (-not (Test-Path $TEMPLATE_FILE)) { Write-Err "Template not found at $TEMPLATE_FILE"; return $false }
|
||||
$temp = New-TemporaryFile
|
||||
Copy-Item -LiteralPath $TEMPLATE_FILE -Destination $temp -Force
|
||||
|
||||
$projectStructure = Get-ProjectStructure -ProjectType $NEW_PROJECT_TYPE
|
||||
$commands = Get-CommandsForLanguage -Lang $NEW_LANG
|
||||
$languageConventions = Get-LanguageConventions -Lang $NEW_LANG
|
||||
|
||||
$escaped_lang = $NEW_LANG
|
||||
$escaped_framework = $NEW_FRAMEWORK
|
||||
$escaped_branch = $CURRENT_BRANCH
|
||||
|
||||
$content = Get-Content -LiteralPath $temp -Raw -Encoding utf8
|
||||
$content = $content -replace '\[PROJECT NAME\]',$ProjectName
|
||||
$content = $content -replace '\[DATE\]',$Date.ToString('yyyy-MM-dd')
|
||||
|
||||
# Build the technology stack string safely
|
||||
$techStackForTemplate = ""
|
||||
if ($escaped_lang -and $escaped_framework) {
|
||||
$techStackForTemplate = "- $escaped_lang + $escaped_framework ($escaped_branch)"
|
||||
} elseif ($escaped_lang) {
|
||||
$techStackForTemplate = "- $escaped_lang ($escaped_branch)"
|
||||
} elseif ($escaped_framework) {
|
||||
$techStackForTemplate = "- $escaped_framework ($escaped_branch)"
|
||||
}
|
||||
|
||||
$content = $content -replace '\[EXTRACTED FROM ALL PLAN.MD FILES\]',$techStackForTemplate
|
||||
# For project structure we manually embed (keep newlines)
|
||||
$escapedStructure = [Regex]::Escape($projectStructure)
|
||||
$content = $content -replace '\[ACTUAL STRUCTURE FROM PLANS\]',$escapedStructure
|
||||
# Replace escaped newlines placeholder after all replacements
|
||||
$content = $content -replace '\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]',$commands
|
||||
$content = $content -replace '\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]',$languageConventions
|
||||
|
||||
# Build the recent changes string safely
|
||||
$recentChangesForTemplate = ""
|
||||
if ($escaped_lang -and $escaped_framework) {
|
||||
$recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_lang} + ${escaped_framework}"
|
||||
} elseif ($escaped_lang) {
|
||||
$recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_lang}"
|
||||
} elseif ($escaped_framework) {
|
||||
$recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_framework}"
|
||||
}
|
||||
|
||||
$content = $content -replace '\[LAST 3 FEATURES AND WHAT THEY ADDED\]',$recentChangesForTemplate
|
||||
# Convert literal \n sequences introduced by Escape to real newlines
|
||||
$content = $content -replace '\\n',[Environment]::NewLine
|
||||
|
||||
# Prepend Cursor frontmatter for .mdc files so rules are auto-included
|
||||
if ($TargetFile -match '\.mdc$') {
|
||||
$frontmatter = @('---','description: Project Development Guidelines','globs: ["**/*"]','alwaysApply: true','---','') -join [Environment]::NewLine
|
||||
$content = $frontmatter + $content
|
||||
}
|
||||
|
||||
$parent = Split-Path -Parent $TargetFile
|
||||
if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent | Out-Null }
|
||||
Set-Content -LiteralPath $TargetFile -Value $content -NoNewline -Encoding utf8
|
||||
Remove-Item $temp -Force
|
||||
return $true
|
||||
}
|
||||
|
||||
function Update-ExistingAgentFile {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$TargetFile,
|
||||
[Parameter(Mandatory=$true)]
|
||||
[datetime]$Date
|
||||
)
|
||||
if (-not (Test-Path $TargetFile)) { return (New-AgentFile -TargetFile $TargetFile -ProjectName (Split-Path $REPO_ROOT -Leaf) -Date $Date) }
|
||||
|
||||
$techStack = Format-TechnologyStack -Lang $NEW_LANG -Framework $NEW_FRAMEWORK
|
||||
$newTechEntries = @()
|
||||
if ($techStack) {
|
||||
$escapedTechStack = [Regex]::Escape($techStack)
|
||||
if (-not (Select-String -Pattern $escapedTechStack -Path $TargetFile -Quiet)) {
|
||||
$newTechEntries += "- $techStack ($CURRENT_BRANCH)"
|
||||
}
|
||||
}
|
||||
if ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) {
|
||||
$escapedDB = [Regex]::Escape($NEW_DB)
|
||||
if (-not (Select-String -Pattern $escapedDB -Path $TargetFile -Quiet)) {
|
||||
$newTechEntries += "- $NEW_DB ($CURRENT_BRANCH)"
|
||||
}
|
||||
}
|
||||
$newChangeEntry = ''
|
||||
if ($techStack) { $newChangeEntry = "- ${CURRENT_BRANCH}: Added ${techStack}" }
|
||||
elseif ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) { $newChangeEntry = "- ${CURRENT_BRANCH}: Added ${NEW_DB}" }
|
||||
|
||||
$lines = Get-Content -LiteralPath $TargetFile -Encoding utf8
|
||||
$output = New-Object System.Collections.Generic.List[string]
|
||||
$inTech = $false; $inChanges = $false; $techAdded = $false; $changeAdded = $false; $existingChanges = 0
|
||||
|
||||
for ($i=0; $i -lt $lines.Count; $i++) {
|
||||
$line = $lines[$i]
|
||||
if ($line -eq '## Active Technologies') {
|
||||
$output.Add($line)
|
||||
$inTech = $true
|
||||
continue
|
||||
}
|
||||
if ($inTech -and $line -match '^##\s') {
|
||||
if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true }
|
||||
$output.Add($line); $inTech = $false; continue
|
||||
}
|
||||
if ($inTech -and [string]::IsNullOrWhiteSpace($line)) {
|
||||
if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true }
|
||||
$output.Add($line); continue
|
||||
}
|
||||
if ($line -eq '## Recent Changes') {
|
||||
$output.Add($line)
|
||||
if ($newChangeEntry) { $output.Add($newChangeEntry); $changeAdded = $true }
|
||||
$inChanges = $true
|
||||
continue
|
||||
}
|
||||
if ($inChanges -and $line -match '^##\s') { $output.Add($line); $inChanges = $false; continue }
|
||||
if ($inChanges -and $line -match '^- ') {
|
||||
if ($existingChanges -lt 2) { $output.Add($line); $existingChanges++ }
|
||||
continue
|
||||
}
|
||||
if ($line -match '\*\*Last updated\*\*: .*\d{4}-\d{2}-\d{2}') {
|
||||
$output.Add(($line -replace '\d{4}-\d{2}-\d{2}',$Date.ToString('yyyy-MM-dd')))
|
||||
continue
|
||||
}
|
||||
$output.Add($line)
|
||||
}
|
||||
|
||||
# Post-loop check: if we're still in the Active Technologies section and haven't added new entries
|
||||
if ($inTech -and -not $techAdded -and $newTechEntries.Count -gt 0) {
|
||||
$newTechEntries | ForEach-Object { $output.Add($_) }
|
||||
}
|
||||
|
||||
# Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion
|
||||
if ($TargetFile -match '\.mdc$' -and $output.Count -gt 0 -and $output[0] -ne '---') {
|
||||
$frontmatter = @('---','description: Project Development Guidelines','globs: ["**/*"]','alwaysApply: true','---','')
|
||||
$output.InsertRange(0, $frontmatter)
|
||||
}
|
||||
|
||||
Set-Content -LiteralPath $TargetFile -Value ($output -join [Environment]::NewLine) -Encoding utf8
|
||||
return $true
|
||||
}
|
||||
|
||||
function Update-AgentFile {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$TargetFile,
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$AgentName
|
||||
)
|
||||
if (-not $TargetFile -or -not $AgentName) { Write-Err 'Update-AgentFile requires TargetFile and AgentName'; return $false }
|
||||
Write-Info "Updating $AgentName context file: $TargetFile"
|
||||
$projectName = Split-Path $REPO_ROOT -Leaf
|
||||
$date = Get-Date
|
||||
|
||||
$dir = Split-Path -Parent $TargetFile
|
||||
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null }
|
||||
|
||||
if (-not (Test-Path $TargetFile)) {
|
||||
if (New-AgentFile -TargetFile $TargetFile -ProjectName $projectName -Date $date) { Write-Success "Created new $AgentName context file" } else { Write-Err 'Failed to create new agent file'; return $false }
|
||||
} else {
|
||||
try {
|
||||
if (Update-ExistingAgentFile -TargetFile $TargetFile -Date $date) { Write-Success "Updated existing $AgentName context file" } else { Write-Err 'Failed to update agent file'; return $false }
|
||||
} catch {
|
||||
Write-Err "Cannot access or update existing file: $TargetFile. $_"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Update-SpecificAgent {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Type
|
||||
)
|
||||
switch ($Type) {
|
||||
'claude' { Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code' }
|
||||
'gemini' { Update-AgentFile -TargetFile $GEMINI_FILE -AgentName 'Gemini CLI' }
|
||||
'copilot' { Update-AgentFile -TargetFile $COPILOT_FILE -AgentName 'GitHub Copilot' }
|
||||
'cursor-agent' { Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE' }
|
||||
'qwen' { Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code' }
|
||||
'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' }
|
||||
'codex' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex CLI' }
|
||||
'windsurf' { Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf' }
|
||||
'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' }
|
||||
'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' }
|
||||
'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' }
|
||||
'codebuddy' { Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI' }
|
||||
'qodercli' { Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI' }
|
||||
'amp' { Update-AgentFile -TargetFile $AMP_FILE -AgentName 'Amp' }
|
||||
'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' }
|
||||
'kiro-cli' { Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI' }
|
||||
'agy' { Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity' }
|
||||
'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' }
|
||||
'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' }
|
||||
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|generic'; return $false }
|
||||
}
|
||||
}
|
||||
|
||||
function Update-AllExistingAgents {
|
||||
$found = $false
|
||||
$ok = $true
|
||||
if (Test-Path $CLAUDE_FILE) { if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $GEMINI_FILE) { if (-not (Update-AgentFile -TargetFile $GEMINI_FILE -AgentName 'Gemini CLI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $COPILOT_FILE) { if (-not (Update-AgentFile -TargetFile $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $CURSOR_FILE) { if (-not (Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $QWEN_FILE) { if (-not (Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $AGENTS_FILE) { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $WINDSURF_FILE) { if (-not (Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $AUGGIE_FILE) { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $ROO_FILE) { if (-not (Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $CODEBUDDY_FILE) { if (-not (Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $QODER_FILE) { if (-not (Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $SHAI_FILE) { if (-not (Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $KIRO_FILE) { if (-not (Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $AGY_FILE) { if (-not (Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true }
|
||||
if (-not $found) {
|
||||
Write-Info 'No existing agent files found, creating default Claude file...'
|
||||
if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }
|
||||
}
|
||||
return $ok
|
||||
}
|
||||
|
||||
function Print-Summary {
|
||||
Write-Host ''
|
||||
Write-Info 'Summary of changes:'
|
||||
if ($NEW_LANG) { Write-Host " - Added language: $NEW_LANG" }
|
||||
if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" }
|
||||
if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" }
|
||||
Write-Host ''
|
||||
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|generic]'
|
||||
}
|
||||
|
||||
function Main {
|
||||
Validate-Environment
|
||||
Write-Info "=== Updating agent context files for feature $CURRENT_BRANCH ==="
|
||||
if (-not (Parse-PlanData -PlanFile $NEW_PLAN)) { Write-Err 'Failed to parse plan data'; exit 1 }
|
||||
$success = $true
|
||||
if ($AgentType) {
|
||||
Write-Info "Updating specific agent: $AgentType"
|
||||
if (-not (Update-SpecificAgent -Type $AgentType)) { $success = $false }
|
||||
}
|
||||
else {
|
||||
Write-Info 'No agent specified, updating all existing agent files...'
|
||||
if (-not (Update-AllExistingAgents)) { $success = $false }
|
||||
}
|
||||
Print-Summary
|
||||
if ($success) { Write-Success 'Agent context update completed successfully'; exit 0 } else { Write-Err 'Agent context update completed with errors'; exit 1 }
|
||||
}
|
||||
|
||||
Main
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# CASAN H5 Risk Registry Auto-Updater
|
||||
# Usage:
|
||||
# update-risk-registry.ps1 -ActionName <name> [-DefaultRisk <low|medium|high>]
|
||||
#
|
||||
# If action not in registry, adds it with DefaultRisk (default: high = fail-secure).
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$ActionName,
|
||||
[ValidateSet("low","medium","high")][string]$DefaultRisk = "high"
|
||||
)
|
||||
|
||||
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
|
||||
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
|
||||
$registryPath = Join-Path $projectRoot ".specify/governance/risk-registry.yaml"
|
||||
|
||||
if (!(Test-Path $registryPath)) {
|
||||
Write-Warning "Risk registry not found at $registryPath — skipping auto-update"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$content = Get-Content $registryPath -Raw
|
||||
if ($content -match "(?m)^\s+-\s+id:\s+$([regex]::Escape($ActionName))") {
|
||||
Write-Output "RISK_REGISTRY_EXISTS: $ActionName already registered"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
$newEntry = @"
|
||||
|
||||
- id: $ActionName
|
||||
risk_level: $DefaultRisk
|
||||
added_by: auto_update
|
||||
added_at: $timestamp
|
||||
note: "Auto-added (unknown action — defaulting to $DefaultRisk per fail-secure policy)"
|
||||
"@
|
||||
|
||||
# Append before the last line (end of YAML file)
|
||||
$lines = (Get-Content $registryPath) + $newEntry.Split("`n")
|
||||
Set-Content -Path $registryPath -Value $lines -Encoding UTF8
|
||||
|
||||
Write-Warning "[RISK_REGISTRY] Auto-added unknown action '$ActionName' with risk_level=$DefaultRisk (fail-secure)"
|
||||
Write-Output "RISK_REGISTRY_UPDATED: $ActionName risk=$DefaultRisk"
|
||||
Reference in New Issue
Block a user