state-management.sh
1 #!/usr/bin/env bash 2 # APC State Management Module (macOS/Linux) 3 # Provides standardized state management for APC plugin development workflow. 4 # Source this file: . scripts/state-management.sh 5 6 set -euo pipefail 7 8 # --- PATH RESOLUTION --- 9 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 10 REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" 11 12 # --- SCHEMA CONSTANTS --- 13 STATE_PHASES=("ideation" "plan" "design" "code" "ship" "complete") 14 STATE_FRAMEWORKS=("visage" "webview" "pending") 15 STATE_REQUIRED_FIELDS=("plugin_name" "version" "current_phase" "ui_framework" "complexity_score" "created_at" "last_modified" "phase_history" "validation" "framework_selection" "error_recovery") 16 STATE_VALIDATION_FIELDS=("creative_brief_exists" "parameter_spec_exists" "architecture_defined" "ui_framework_selected" "design_complete" "code_complete" "tests_passed" "ship_ready") 17 18 # --- HELPERS --- 19 20 _check_jq() { 21 if ! command -v jq &>/dev/null; then 22 echo "ERROR: jq is required for state management. Install with: brew install jq" >&2 23 return 1 24 fi 25 } 26 27 _iso_date() { 28 date -u +"%Y-%m-%dT%H:%M:%SZ" 29 } 30 31 _array_contains() { 32 local needle="$1"; shift 33 for item in "$@"; do 34 [[ "$item" == "$needle" ]] && return 0 35 done 36 return 1 37 } 38 39 _phase_index() { 40 local phase="$1" 41 for i in "${!STATE_PHASES[@]}"; do 42 [[ "${STATE_PHASES[$i]}" == "$phase" ]] && echo "$i" && return 0 43 done 44 echo "-1" 45 } 46 47 # --- FUNCTIONS --- 48 49 new_plugin_state() { 50 # Initialize a new plugin state from template 51 # Usage: new_plugin_state <PluginName> <PluginPath> 52 _check_jq || return 1 53 local plugin_name="$1" 54 local plugin_path="$2" 55 56 # Try multiple locations for the template 57 local template_path="" 58 local candidates=( 59 "$REPO_ROOT/.kilocode/templates/status-template.json" 60 "$REPO_ROOT/templates/status-template.json" 61 "$SCRIPT_DIR/../templates/status-template.json" 62 ) 63 for path in "${candidates[@]}"; do 64 if [[ -f "$path" ]]; then 65 template_path="$path" 66 break 67 fi 68 done 69 70 if [[ -z "$template_path" ]]; then 71 echo "WARNING: Status template not found." >&2 72 return 1 73 fi 74 75 local now 76 now="$(_iso_date)" 77 local status_path="$plugin_path/status.json" 78 79 jq --arg name "$plugin_name" --arg now "$now" \ 80 '.plugin_name = $name | .created_at = $now | .last_modified = $now' \ 81 "$template_path" > "$status_path" 82 83 echo "Initialized state for $plugin_name" 84 } 85 86 get_plugin_state() { 87 # Read and return plugin state JSON 88 # Usage: get_plugin_state <PluginPath> 89 _check_jq || return 1 90 local plugin_path="$1" 91 local status_path="$plugin_path/status.json" 92 93 if [[ ! -f "$status_path" ]]; then 94 return 1 95 fi 96 97 cat "$status_path" 98 } 99 100 get_state_field() { 101 # Get a specific field from state using jq query 102 # Usage: get_state_field <PluginPath> <jq_query> 103 # Example: get_state_field "plugins/MyPlugin" ".ui_framework" 104 _check_jq || return 1 105 local plugin_path="$1" 106 local query="$2" 107 local status_path="$plugin_path/status.json" 108 109 if [[ ! -f "$status_path" ]]; then 110 return 1 111 fi 112 113 jq -r "$query" "$status_path" 114 } 115 116 update_plugin_state() { 117 # Update plugin state with key=value pairs, optional phase and framework 118 # Usage: update_plugin_state <PluginPath> [--phase <phase>] [--framework <framework>] [key=value ...] 119 _check_jq || return 1 120 local plugin_path="$1"; shift 121 local status_path="$plugin_path/status.json" 122 local phase="" 123 local framework="" 124 local -a updates=() 125 126 if [[ ! -f "$status_path" ]]; then 127 echo "WARNING: Status file not found at $status_path" >&2 128 return 1 129 fi 130 131 # Parse arguments 132 while [[ $# -gt 0 ]]; do 133 case "$1" in 134 --phase) phase="$2"; shift 2 ;; 135 --framework) framework="$2"; shift 2 ;; 136 *=*) updates+=("$1"); shift ;; 137 *) shift ;; 138 esac 139 done 140 141 local jq_filter="." 142 local now 143 now="$(_iso_date)" 144 145 # Apply key=value updates (supports dot notation like "validation.build_completed=true") 146 for update in "${updates[@]}"; do 147 local key="${update%%=*}" 148 local value="${update#*=}" 149 150 # Convert dot notation to jq path 151 local jq_path 152 jq_path="$(echo "$key" | sed 's/\./"."/g')" 153 jq_path=".\"$jq_path\"" 154 155 # Detect value type 156 if [[ "$value" == "true" || "$value" == "false" ]]; then 157 jq_filter="$jq_filter | $jq_path = $value" 158 elif [[ "$value" =~ ^[0-9]+$ ]]; then 159 jq_filter="$jq_filter | $jq_path = $value" 160 elif [[ "$value" == "null" ]]; then 161 jq_filter="$jq_filter | $jq_path = null" 162 else 163 jq_filter="$jq_filter | $jq_path = \"$value\"" 164 fi 165 done 166 167 # Update phase if specified 168 if [[ -n "$phase" ]]; then 169 if ! _array_contains "$phase" "${STATE_PHASES[@]}"; then 170 echo "WARNING: Invalid phase: $phase" >&2 171 return 1 172 fi 173 local fw_selected 174 fw_selected="${framework:-$(jq -r '.ui_framework' "$status_path")}" 175 jq_filter="$jq_filter | .current_phase = \"$phase\" | .last_modified = \"$now\"" 176 jq_filter="$jq_filter | .phase_history += [{\"phase\": \"$phase\", \"completed_at\": \"$now\", \"framework_selected\": \"$fw_selected\"}]" 177 fi 178 179 # Update framework if specified 180 if [[ -n "$framework" ]]; then 181 if ! _array_contains "$framework" "${STATE_FRAMEWORKS[@]}"; then 182 echo "WARNING: Invalid framework: $framework" >&2 183 return 1 184 fi 185 jq_filter="$jq_filter | .ui_framework = \"$framework\" | .framework_selection.decision = \"$framework\" | .last_modified = \"$now\"" 186 fi 187 188 # Apply updates 189 local tmp_file="${status_path}.tmp" 190 if jq "$jq_filter" "$status_path" > "$tmp_file"; then 191 # Validate before saving 192 if test_state_schema "$tmp_file"; then 193 mv "$tmp_file" "$status_path" 194 echo "Updated state${phase:+: $phase}${framework:+ ($framework)}" 195 return 0 196 else 197 rm -f "$tmp_file" 198 echo "WARNING: State validation failed during update." >&2 199 return 1 200 fi 201 else 202 rm -f "$tmp_file" 203 echo "WARNING: jq filter failed." >&2 204 return 1 205 fi 206 } 207 208 test_plugin_state() { 209 # Validate plugin state prerequisites 210 # Usage: test_plugin_state <PluginPath> [--required-phase <phase>] [--required-files <file1> <file2> ...] 211 _check_jq || return 1 212 local plugin_path="$1"; shift 213 local required_phase="" 214 local -a required_files=() 215 216 while [[ $# -gt 0 ]]; do 217 case "$1" in 218 --required-phase) required_phase="$2"; shift 2 ;; 219 --required-files) shift; while [[ $# -gt 0 && "$1" != --* ]]; do required_files+=("$1"); shift; done ;; 220 *) shift ;; 221 esac 222 done 223 224 local status_path="$plugin_path/status.json" 225 if [[ ! -f "$status_path" ]]; then 226 echo "WARNING: Status file not found" >&2 227 return 1 228 fi 229 230 # Schema validation 231 if ! test_state_schema "$status_path"; then 232 echo "WARNING: State schema validation failed" >&2 233 return 1 234 fi 235 236 # Phase prerequisite check 237 if [[ -n "$required_phase" ]]; then 238 local current_phase 239 current_phase="$(jq -r '.current_phase' "$status_path")" 240 local required_idx current_idx 241 required_idx="$(_phase_index "$required_phase")" 242 current_idx="$(_phase_index "$current_phase")" 243 244 if (( current_idx < required_idx )); then 245 echo "WARNING: Cannot proceed: Current phase '$current_phase' must complete '$required_phase' first" >&2 246 return 1 247 fi 248 fi 249 250 # Required files check 251 for file in "${required_files[@]}"; do 252 if [[ ! -f "$plugin_path/$file" ]]; then 253 echo "WARNING: Required file missing: $file" >&2 254 return 1 255 fi 256 done 257 258 return 0 259 } 260 261 test_state_schema() { 262 # Validate state JSON against schema 263 # Usage: test_state_schema <status.json path> 264 _check_jq || return 1 265 local file="$1" 266 267 if [[ ! -f "$file" ]]; then 268 return 1 269 fi 270 271 # Check required fields exist 272 for field in "${STATE_REQUIRED_FIELDS[@]}"; do 273 if ! jq -e "has(\"$field\")" "$file" &>/dev/null; then 274 echo "WARNING: Missing required field: $field" >&2 275 return 1 276 fi 277 done 278 279 # Check phase validity 280 local phase 281 phase="$(jq -r '.current_phase' "$file")" 282 if ! _array_contains "$phase" "${STATE_PHASES[@]}"; then 283 echo "WARNING: Invalid phase: $phase" >&2 284 return 1 285 fi 286 287 # Check framework validity 288 local fw 289 fw="$(jq -r '.ui_framework' "$file")" 290 if ! _array_contains "$fw" "${STATE_FRAMEWORKS[@]}"; then 291 echo "WARNING: Invalid framework: $fw" >&2 292 return 1 293 fi 294 295 # Check validation fields 296 for field in "${STATE_VALIDATION_FIELDS[@]}"; do 297 if ! jq -e ".validation | has(\"$field\")" "$file" &>/dev/null; then 298 echo "WARNING: Missing validation field: $field" >&2 299 return 1 300 fi 301 done 302 303 return 0 304 } 305 306 backup_plugin_state() { 307 # Create a backup of status.json 308 # Usage: backup_plugin_state <PluginPath> 309 _check_jq || return 1 310 local plugin_path="$1" 311 local status_path="$plugin_path/status.json" 312 313 if [[ ! -f "$status_path" ]]; then 314 return 1 315 fi 316 317 local backup_dir="$plugin_path/_state_backups" 318 mkdir -p "$backup_dir" 319 320 local timestamp 321 timestamp="$(date +%Y%m%d_%H%M%S)" 322 local backup_file="$backup_dir/status_backup_${timestamp}.json" 323 324 cp "$status_path" "$backup_file" 325 echo "State backed up to $backup_file" 326 327 # Update error recovery info in current state 328 local tmp_file="${status_path}.tmp" 329 jq --arg bf "$backup_file" \ 330 '.error_recovery.last_backup = $bf | .error_recovery.rollback_available = true' \ 331 "$status_path" > "$tmp_file" && mv "$tmp_file" "$status_path" 332 333 echo "$backup_file" 334 } 335 336 restore_plugin_state() { 337 # Restore status.json from a backup 338 # Usage: restore_plugin_state <PluginPath> [backup_file] 339 _check_jq || return 1 340 local plugin_path="$1" 341 local backup_file="${2:-}" 342 local status_path="$plugin_path/status.json" 343 local backup_dir="$plugin_path/_state_backups" 344 345 if [[ ! -d "$backup_dir" ]]; then 346 echo "WARNING: No backup directory found" >&2 347 return 1 348 fi 349 350 if [[ -n "$backup_file" && -f "$backup_file" ]]; then 351 local source="$backup_file" 352 else 353 # Get latest backup 354 local source 355 source="$(ls -t "$backup_dir"/status_backup_*.json 2>/dev/null | head -1)" 356 if [[ -z "$source" ]]; then 357 echo "WARNING: No backup files found" >&2 358 return 1 359 fi 360 fi 361 362 echo "Restoring state from $source" 363 cp "$source" "$status_path" 364 365 # Update error recovery info 366 local now 367 now="$(_iso_date)" 368 local tmp_file="${status_path}.tmp" 369 jq --arg msg "Rollback performed from $source at $now" \ 370 '.error_recovery.rollback_available = false | .error_recovery.error_log += [$msg]' \ 371 "$status_path" > "$tmp_file" && mv "$tmp_file" "$status_path" 372 373 echo "State restored" 374 } 375 376 add_state_error() { 377 # Append an error message to error_recovery.error_log 378 # Usage: add_state_error <PluginPath> <ErrorMessage> 379 _check_jq || return 1 380 local plugin_path="$1" 381 local error_message="$2" 382 local status_path="$plugin_path/status.json" 383 384 if [[ ! -f "$status_path" ]]; then 385 return 1 386 fi 387 388 local now 389 now="$(_iso_date)" 390 local tmp_file="${status_path}.tmp" 391 jq --arg msg "$now: $error_message" \ 392 '.error_recovery.error_log += [$msg]' \ 393 "$status_path" > "$tmp_file" && mv "$tmp_file" "$status_path" 394 } 395 396 set_plugin_framework() { 397 # Set the UI framework for a plugin 398 # Usage: set_plugin_framework <PluginPath> <visage|webview> <Rationale> 399 _check_jq || return 1 400 local plugin_path="$1" 401 local framework="$2" 402 local rationale="$3" 403 404 if [[ "$framework" != "visage" && "$framework" != "webview" ]]; then 405 echo "ERROR: Framework must be 'visage' or 'webview'" >&2 406 return 1 407 fi 408 409 update_plugin_state "$plugin_path" \ 410 --framework "$framework" \ 411 "framework_selection.rationale=$rationale" \ 412 "framework_selection.implementation_strategy=single-pass" 413 }