#!/usr/bin/env bash set -euo pipefail usage() { cat <<'EOF' Usage: scripts/bitlesson-validate-delta.sh \ --summary-file \ --bitlesson-file \ --bitlesson-relpath \ --allow-empty-none \ --template-dir \ --current-round EOF } SUMMARY_FILE="" BITLESSON_FILE="" BITLESSON_FILE_REL="" BITLESSON_ALLOW_EMPTY_NONE="" TEMPLATE_DIR="" CURRENT_ROUND="" while [[ $# -gt 0 ]]; do case "$1" in --summary-file) SUMMARY_FILE="${2:-}" shift 2 ;; --bitlesson-file) BITLESSON_FILE="${2:-}" shift 2 ;; --bitlesson-relpath) BITLESSON_FILE_REL="${2:-}" shift 2 ;; --allow-empty-none) BITLESSON_ALLOW_EMPTY_NONE="${2:-}" shift 2 ;; --template-dir) TEMPLATE_DIR="${2:-}" shift 2 ;; --current-round) CURRENT_ROUND="${2:-}" shift 2 ;; -h|--help) usage exit 0 ;; *) echo "Error: Unknown argument: $1" >&2 usage >&2 exit 1 ;; esac done if [[ -z "$SUMMARY_FILE" ]] || [[ -z "$BITLESSON_FILE" ]] || [[ -z "$BITLESSON_FILE_REL" ]] || \ [[ -z "$BITLESSON_ALLOW_EMPTY_NONE" ]] || [[ -z "$TEMPLATE_DIR" ]] || [[ -z "$CURRENT_ROUND" ]]; then echo "Error: Missing required argument(s)" >&2 usage >&2 exit 1 fi if [[ ! -f "$SUMMARY_FILE" ]]; then echo "Error: Summary file not found: $SUMMARY_FILE" >&2 exit 1 fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" source "$PROJECT_ROOT/hooks/lib/template-loader.sh" block_exit() { local reason="$1" local msg="$2" jq -n \ --arg reason "$reason" \ --arg msg "$msg" \ '{ "decision": "block", "reason": $reason, "systemMessage": $msg }' exit 0 } extract_bitlesson_delta_block() { local mode="$1" awk -v mode="$mode" ' BEGIN { in_delta = 0 found_delta = 0 in_fence = 0 fence_delim = "" in_html_comment = 0 } function update_fence_state(line) { if (in_html_comment) { return } if (!in_fence && line ~ /^```/) { in_fence = 1 fence_delim = "```" return } if (!in_fence && line ~ /^~~~/) { in_fence = 1 fence_delim = "~~~" return } if (in_fence && fence_delim == "```" && line ~ /^```/) { in_fence = 0 fence_delim = "" return } if (in_fence && fence_delim == "~~~" && line ~ /^~~~/) { in_fence = 0 fence_delim = "" } } function update_html_comment_state(line, start_idx, end_idx) { if (in_fence) { return } start_idx = index(line, "") if (in_html_comment) { if (end_idx > 0) { in_html_comment = 0 } return } if (start_idx > 0 && (end_idx == 0 || end_idx < start_idx)) { in_html_comment = 1 } } { if (!in_fence && !in_html_comment && tolower($0) ~ /^##[[:space:]]*bitlesson delta[[:space:]]*$/) { found_delta = 1 in_delta = 1 next } if (in_delta && !in_fence && !in_html_comment && /^##[[:space:]]+/) { in_delta = 0 } if (mode == "extract" && in_delta) { print } update_fence_state($0) update_html_comment_state($0) } END { if (mode == "detect" && !found_delta) { exit 1 } } ' "$SUMMARY_FILE" } if ! extract_bitlesson_delta_block detect >/dev/null; then FALLBACK=$(cat <<'EOF' # BitLesson Delta Missing Your summary is missing the required `## BitLesson Delta` section. Required minimal format: ```markdown ## BitLesson Delta - Action: none|add|update - Lesson ID(s): - Notes: ``` EOF ) REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/bitlesson-delta-missing.md" "$FALLBACK") block_exit "$REASON" "Loop: Summary missing BitLesson Delta section (round $CURRENT_ROUND)" fi BITLESSON_DELTA_BLOCK=$(extract_bitlesson_delta_block extract) BITLESSON_ACTION_CANDIDATES=$(echo "$BITLESSON_DELTA_BLOCK" | sed -nE 's/^[[:space:]-]*Action:[[:space:]]*([A-Za-z]+)[[:space:]]*$/\1/p' | tr '[:upper:]' '[:lower:]') BITLESSON_ACTION_COUNT=$(echo "$BITLESSON_ACTION_CANDIDATES" | awk 'NF{c++} END{print c+0}') BITLESSON_ACTION=$(echo "$BITLESSON_ACTION_CANDIDATES" | awk 'NF{print; exit}') if [[ "$BITLESSON_ACTION_COUNT" -ne 1 ]] || [[ "$BITLESSON_ACTION" != "none" && "$BITLESSON_ACTION" != "add" && "$BITLESSON_ACTION" != "update" ]]; then FALLBACK=$(cat <<'EOF' # Invalid BitLesson Delta Action Your `## BitLesson Delta` section exists, but it must include one action: - `none` - `add` - `update` EOF ) REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/bitlesson-delta-invalid.md" "$FALLBACK") block_exit "$REASON" "Loop: BitLesson Delta must include action none/add/update (round $CURRENT_ROUND)" fi BITLESSON_IDS_RAW=$(echo "$BITLESSON_DELTA_BLOCK" | sed -nE 's/^[[:space:]-]*Lesson ID\(s\):[[:space:]]*(.*)$/\1/p' | head -n1) if [[ -z "$BITLESSON_IDS_RAW" ]]; then BITLESSON_IDS_RAW=$(echo "$BITLESSON_DELTA_BLOCK" | sed -nE 's/^[[:space:]-]*Lesson IDs:[[:space:]]*(.*)$/\1/p' | head -n1) fi BITLESSON_IDS_RAW=$(echo "$BITLESSON_IDS_RAW" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') BITLESSON_IDS_UPPER=$(echo "$BITLESSON_IDS_RAW" | tr '[:lower:]' '[:upper:]') CONCRETE_BITLESSON_COUNT=0 if [[ -f "$BITLESSON_FILE" ]]; then CONCRETE_BITLESSON_COUNT=$(awk ' /^Lesson ID:[[:space:]]*/ { id=$0 sub(/^Lesson ID:[[:space:]]*/, "", id) gsub(/^[[:space:]]+|[[:space:]]+$/, "", id) if (id != "" && id != "" && id != "BL-YYYYMMDD-short-name" && id !~ /^<.*>$/) { count++ } } END { print count+0 } ' "$BITLESSON_FILE" 2>/dev/null) fi if [[ "$BITLESSON_ACTION" == "none" ]]; then if [[ -n "$BITLESSON_IDS_RAW" ]] && [[ "$BITLESSON_IDS_UPPER" != "NONE" ]]; then FALLBACK=$(cat <<'EOF' # BitLesson Delta Inconsistent `Action: none` requires `Lesson ID(s): NONE` (or leaving Lesson ID(s) empty). EOF ) REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/bitlesson-delta-inconsistent.md" "$FALLBACK") block_exit "$REASON" "Loop: BitLesson Delta inconsistent for action none (round $CURRENT_ROUND)" fi if [[ "$CONCRETE_BITLESSON_COUNT" -eq 0 ]] && [[ "$BITLESSON_ALLOW_EMPTY_NONE" != "true" ]]; then FALLBACK=$(cat <<'EOF' # BitLesson Recording Required `Action: none` is not allowed in round {{CURRENT_ROUND}} when {{BITLESSON_FILE}} still has no concrete lesson entries. If this round resolves issues discovered in previous rounds, add or update at least one reusable lesson and report `Action: add` or `Action: update`. EOF ) REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/bitlesson-delta-empty-kb.md" "$FALLBACK" \ "CURRENT_ROUND=$CURRENT_ROUND" \ "BITLESSON_FILE=$BITLESSON_FILE_REL") block_exit "$REASON" "Loop: BitLesson entry required for non-zero round (round $CURRENT_ROUND)" fi else if [[ -z "$BITLESSON_IDS_RAW" ]] || [[ "$BITLESSON_IDS_UPPER" == "NONE" ]]; then FALLBACK=$(cat <<'EOF' # BitLesson Delta Inconsistent `Action: {{ACTION}}` requires concrete `Lesson ID(s)` (not `NONE`). EOF ) REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/bitlesson-delta-inconsistent.md" "$FALLBACK" \ "ACTION=$BITLESSON_ACTION") block_exit "$REASON" "Loop: BitLesson Delta missing lesson IDs for action $BITLESSON_ACTION (round $CURRENT_ROUND)" fi BITLESSON_NOTES=$(echo "$BITLESSON_DELTA_BLOCK" | sed -nE 's/^[[:space:]-]*Notes:[[:space:]]*(.*)$/\1/p' | head -n1) BITLESSON_NOTES=$(echo "$BITLESSON_NOTES" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') NOTES_PLACEHOLDER_REGEX='^(\[.*\]|<.*>)$' if [[ -z "$BITLESSON_NOTES" ]] || [[ "$BITLESSON_NOTES" =~ $NOTES_PLACEHOLDER_REGEX ]]; then FALLBACK=$(cat <<'EOF' # BitLesson Delta Missing Notes `Action: {{ACTION}}` requires a `Notes:` field explaining what changed and why. The Notes field must not be empty or contain placeholder text like `[what changed and why]`. EOF ) REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/bitlesson-delta-missing-notes.md" "$FALLBACK" \ "ACTION=$BITLESSON_ACTION") block_exit "$REASON" "Loop: BitLesson Delta missing Notes for action $BITLESSON_ACTION (round $CURRENT_ROUND)" fi if [[ ! -f "$BITLESSON_FILE" ]]; then FALLBACK=$(cat <<'EOF' # BitLesson File Missing Summary declares `Action: {{ACTION}}`, but {{BITLESSON_FILE}} does not exist. EOF ) REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/bitlesson-delta-inconsistent.md" "$FALLBACK" \ "ACTION=$BITLESSON_ACTION" \ "BITLESSON_FILE=$BITLESSON_FILE_REL") block_exit "$REASON" "Loop: BitLesson file missing for action $BITLESSON_ACTION (round $CURRENT_ROUND)" fi INVALID_IDS="" MISSING_IDS="" HAS_ANY_ID=false IFS=',' read -r -a LESSON_ID_ARRAY <<< "$BITLESSON_IDS_RAW" for RAW_ID in "${LESSON_ID_ARRAY[@]}"; do LESSON_ID=$(echo "$RAW_ID" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') [[ -n "$LESSON_ID" ]] || continue HAS_ANY_ID=true if [[ ! "$LESSON_ID" =~ ^BL-[0-9]{8}-[A-Za-z0-9._-]+$ ]]; then INVALID_IDS="${INVALID_IDS} - $LESSON_ID" continue fi if ! awk -v target="$LESSON_ID" ' /^Lesson ID:[[:space:]]*/ { id=$0 sub(/^Lesson ID:[[:space:]]*/, "", id) gsub(/^[[:space:]]+|[[:space:]]+$/, "", id) if (id == target) found=1 } END { exit(found ? 0 : 1) } ' "$BITLESSON_FILE" >/dev/null 2>&1; then MISSING_IDS="${MISSING_IDS} - $LESSON_ID" fi done if [[ "$HAS_ANY_ID" != "true" ]]; then FALLBACK=$(cat <<'EOF' # BitLesson Delta Inconsistent `Action: {{ACTION}}` requires at least one concrete Lesson ID. EOF ) REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/bitlesson-delta-inconsistent.md" "$FALLBACK" \ "ACTION=$BITLESSON_ACTION") block_exit "$REASON" "Loop: BitLesson Delta has no concrete lesson IDs (round $CURRENT_ROUND)" fi if [[ -n "$INVALID_IDS" ]]; then FALLBACK=$(cat <<'EOF' # Invalid BitLesson Lesson ID(s) The following IDs in `## BitLesson Delta` are invalid: {{INVALID_IDS}} Expected format: `BL-YYYYMMDD-short-name`. EOF ) REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/bitlesson-delta-inconsistent.md" "$FALLBACK" \ "INVALID_IDS=$INVALID_IDS") block_exit "$REASON" "Loop: Invalid Lesson ID format in BitLesson Delta (round $CURRENT_ROUND)" fi if [[ -n "$MISSING_IDS" ]]; then FALLBACK=$(cat <<'EOF' # BitLesson Entry Missing Summary declares `Action: {{ACTION}}`, but these Lesson ID(s) are not found in {{BITLESSON_FILE}}: {{MISSING_IDS}} Add/update those entries in {{BITLESSON_FILE}} before exiting. EOF ) REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/bitlesson-delta-inconsistent.md" "$FALLBACK" \ "ACTION=$BITLESSON_ACTION" \ "BITLESSON_FILE=$BITLESSON_FILE_REL" \ "MISSING_IDS=$MISSING_IDS") block_exit "$REASON" "Loop: BitLesson IDs missing in knowledge base (round $CURRENT_ROUND)" fi fi exit 0