specweave 1.0.32 → 1.0.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CLAUDE.md +205 -148
  3. package/README.md +0 -2
  4. package/bin/specweave.js +11 -0
  5. package/dist/src/cli/commands/init.js +1 -1
  6. package/dist/src/cli/commands/init.js.map +1 -1
  7. package/dist/src/cli/commands/update-instructions.d.ts +16 -0
  8. package/dist/src/cli/commands/update-instructions.d.ts.map +1 -0
  9. package/dist/src/cli/commands/update-instructions.js +134 -0
  10. package/dist/src/cli/commands/update-instructions.js.map +1 -0
  11. package/dist/src/cli/helpers/init/directory-structure.d.ts +28 -1
  12. package/dist/src/cli/helpers/init/directory-structure.d.ts.map +1 -1
  13. package/dist/src/cli/helpers/init/directory-structure.js +163 -33
  14. package/dist/src/cli/helpers/init/directory-structure.js.map +1 -1
  15. package/dist/src/cli/helpers/init/index.d.ts +2 -1
  16. package/dist/src/cli/helpers/init/index.d.ts.map +1 -1
  17. package/dist/src/cli/helpers/init/index.js +3 -1
  18. package/dist/src/cli/helpers/init/index.js.map +1 -1
  19. package/dist/src/cli/helpers/init/instruction-file-merger.d.ts +23 -0
  20. package/dist/src/cli/helpers/init/instruction-file-merger.d.ts.map +1 -0
  21. package/dist/src/cli/helpers/init/instruction-file-merger.js +243 -0
  22. package/dist/src/cli/helpers/init/instruction-file-merger.js.map +1 -0
  23. package/dist/src/cli/helpers/init/plugin-installer.js +49 -0
  24. package/dist/src/cli/helpers/init/plugin-installer.js.map +1 -1
  25. package/dist/src/config/types.d.ts +2 -2
  26. package/dist/src/core/living-docs/external-sync-orchestrator.d.ts +26 -0
  27. package/dist/src/core/living-docs/external-sync-orchestrator.d.ts.map +1 -1
  28. package/dist/src/core/living-docs/external-sync-orchestrator.js +61 -0
  29. package/dist/src/core/living-docs/external-sync-orchestrator.js.map +1 -1
  30. package/dist/src/core/living-docs/scaffolding/index.d.ts +12 -0
  31. package/dist/src/core/living-docs/scaffolding/index.d.ts.map +1 -0
  32. package/dist/src/core/living-docs/scaffolding/index.js +15 -0
  33. package/dist/src/core/living-docs/scaffolding/index.js.map +1 -0
  34. package/dist/src/core/living-docs/scaffolding/merger.d.ts +183 -0
  35. package/dist/src/core/living-docs/scaffolding/merger.d.ts.map +1 -0
  36. package/dist/src/core/living-docs/scaffolding/merger.js +523 -0
  37. package/dist/src/core/living-docs/scaffolding/merger.js.map +1 -0
  38. package/dist/src/core/living-docs/scaffolding/scaffold.d.ts +102 -0
  39. package/dist/src/core/living-docs/scaffolding/scaffold.d.ts.map +1 -0
  40. package/dist/src/core/living-docs/scaffolding/scaffold.js +346 -0
  41. package/dist/src/core/living-docs/scaffolding/scaffold.js.map +1 -0
  42. package/dist/src/core/living-docs/scaffolding/template-engine.d.ts +108 -0
  43. package/dist/src/core/living-docs/scaffolding/template-engine.d.ts.map +1 -0
  44. package/dist/src/core/living-docs/scaffolding/template-engine.js +204 -0
  45. package/dist/src/core/living-docs/scaffolding/template-engine.js.map +1 -0
  46. package/dist/src/core/living-docs/sync-helpers/generators.d.ts +38 -2
  47. package/dist/src/core/living-docs/sync-helpers/generators.d.ts.map +1 -1
  48. package/dist/src/core/living-docs/sync-helpers/generators.js +65 -10
  49. package/dist/src/core/living-docs/sync-helpers/generators.js.map +1 -1
  50. package/dist/src/core/living-docs/sync-helpers/index.d.ts +1 -1
  51. package/dist/src/core/living-docs/sync-helpers/index.d.ts.map +1 -1
  52. package/dist/src/core/living-docs/sync-helpers/index.js.map +1 -1
  53. package/dist/src/core/tools/index.d.ts +11 -0
  54. package/dist/src/core/tools/index.d.ts.map +1 -0
  55. package/dist/src/core/tools/index.js +10 -0
  56. package/dist/src/core/tools/index.js.map +1 -0
  57. package/dist/src/core/tools/tool-event-bus.d.ts +33 -0
  58. package/dist/src/core/tools/tool-event-bus.d.ts.map +1 -0
  59. package/dist/src/core/tools/tool-event-bus.js +84 -0
  60. package/dist/src/core/tools/tool-event-bus.js.map +1 -0
  61. package/dist/src/core/tools/tool-index-builder.d.ts +27 -0
  62. package/dist/src/core/tools/tool-index-builder.d.ts.map +1 -0
  63. package/dist/src/core/tools/tool-index-builder.js +289 -0
  64. package/dist/src/core/tools/tool-index-builder.js.map +1 -0
  65. package/dist/src/core/tools/tool-registry.d.ts +51 -0
  66. package/dist/src/core/tools/tool-registry.d.ts.map +1 -0
  67. package/dist/src/core/tools/tool-registry.js +224 -0
  68. package/dist/src/core/tools/tool-registry.js.map +1 -0
  69. package/dist/src/core/tools/tool-search-engine.d.ts +22 -0
  70. package/dist/src/core/tools/tool-search-engine.d.ts.map +1 -0
  71. package/dist/src/core/tools/tool-search-engine.js +174 -0
  72. package/dist/src/core/tools/tool-search-engine.js.map +1 -0
  73. package/dist/src/core/tools/types/tool-registry-types.d.ts +112 -0
  74. package/dist/src/core/tools/types/tool-registry-types.d.ts.map +1 -0
  75. package/dist/src/core/tools/types/tool-registry-types.js +7 -0
  76. package/dist/src/core/tools/types/tool-registry-types.js.map +1 -0
  77. package/dist/src/init/compliance/types.d.ts +1 -1
  78. package/package.json +1 -1
  79. package/plugins/specweave/hooks/hooks.json +3 -13
  80. package/plugins/specweave/hooks/lib/common-setup.sh +47 -321
  81. package/plugins/specweave/hooks/lib/migrate-increment-work.sh +5 -5
  82. package/plugins/specweave/hooks/lib/sync-spec-content.sh +5 -5
  83. package/plugins/specweave/hooks/universal/dispatcher.mjs +4 -5
  84. package/plugins/specweave/hooks/universal/fail-fast-wrapper.sh +43 -296
  85. package/plugins/specweave/hooks/universal/hook-wrapper.sh +3 -1
  86. package/plugins/specweave/hooks/user-prompt-submit.sh +1 -1
  87. package/plugins/specweave/hooks/v2/dispatchers/post-tool-use.sh +2 -2
  88. package/plugins/specweave/hooks/v2/dispatchers/session-start.sh +1 -10
  89. package/plugins/specweave/hooks/v2/guards/completion-guard.sh +12 -29
  90. package/plugins/specweave/hooks/v2/guards/increment-duplicate-guard.sh +27 -29
  91. package/plugins/specweave/hooks/v2/guards/metadata-json-guard.sh +10 -4
  92. package/plugins/specweave/hooks/v2/guards/spec-validation-guard.sh +139 -0
  93. package/plugins/specweave/hooks/v2/guards/task-ac-sync-guard.sh +4 -2
  94. package/plugins/specweave/hooks/v2/session-end.sh +3 -1
  95. package/plugins/specweave/hooks/v2/session-start.sh +3 -1
  96. package/plugins/specweave/skills/increment-planner/templates/plan.md +14 -0
  97. package/plugins/specweave/skills/update-instructions/SKILL.md +80 -0
  98. package/plugins/specweave-ado/hooks/post-living-docs-update.sh +1 -1
  99. package/plugins/specweave-mobile/README.md +55 -35
  100. package/plugins/specweave-mobile/agents/mobile-architect/AGENT.md +805 -329
  101. package/plugins/specweave-mobile/skills/expo-workflow/SKILL.md +226 -9
  102. package/plugins/specweave-mobile/skills/native-modules/SKILL.md +221 -20
  103. package/plugins/specweave-mobile/skills/performance-optimization/SKILL.md +186 -14
  104. package/plugins/specweave-mobile/skills/react-native-setup/SKILL.md +151 -54
  105. package/plugins/specweave-release/commands/npm.md +61 -17
  106. package/plugins/specweave-release/hooks/post-task-completion.sh +2 -3
  107. package/src/templates/AGENTS.md.template +34 -0
  108. package/src/templates/CLAUDE.md.template +121 -155
  109. package/plugins/specweave/hooks/config-env-separator.sh +0 -99
  110. package/plugins/specweave/hooks/github-metadata-guard.sh +0 -73
  111. package/plugins/specweave/hooks/lib/circuit-breaker.sh +0 -381
  112. package/plugins/specweave/hooks/lib/crash-prevention.sh +0 -336
  113. package/plugins/specweave/hooks/lib/logging.sh +0 -231
  114. package/plugins/specweave/hooks/lib/metrics.sh +0 -347
  115. package/plugins/specweave/hooks/lib/semaphore.sh +0 -216
  116. package/plugins/specweave/hooks/project-folder-guard.sh +0 -274
  117. package/plugins/specweave/hooks/spec-project-validator.sh +0 -210
  118. package/plugins/specweave/hooks/v2/guards/bash-file-guard.sh +0 -212
  119. package/plugins/specweave/hooks/v2/guards/bash-file-guard.test.sh +0 -163
  120. package/plugins/specweave/hooks/v2/guards/features-folder-guard.sh +0 -51
  121. package/plugins/specweave/hooks/v2/guards/increment-root-guard.sh +0 -63
  122. package/plugins/specweave/hooks/v2/guards/per-us-project-validator.sh +0 -335
  123. package/plugins/specweave/hooks/v2/guards/per-us-project-validator.test.sh +0 -406
@@ -1,347 +0,0 @@
1
- #!/bin/bash
2
- # metrics.sh - Hook Metrics Collection and Health Monitoring
3
- #
4
- # FEATURES:
5
- # - Execution time tracking (p50, p95, p99)
6
- # - Success/failure rate tracking
7
- # - Request throughput (requests/second)
8
- # - Concurrent execution tracking
9
- # - Health score calculation
10
- # - Metrics aggregation and rollup
11
- #
12
- # STORAGE:
13
- # - Ring buffer for recent metrics (last 1000 entries)
14
- # - Hourly rollups for historical data
15
- # - Compact binary-like format for efficiency
16
- #
17
- # USAGE:
18
- # source metrics.sh
19
- # metrics_init "hook-name"
20
- # metrics_start_request
21
- # # ... do work ...
22
- # metrics_end_request "success" # or "failure", "timeout", "skipped"
23
- #
24
- # v1.0.0 - Initial implementation (2025-12-17)
25
-
26
- set -o pipefail
27
-
28
- # === Configuration ===
29
- METRICS_DIR="${SPECWEAVE_STATE_DIR:-.specweave/state}/metrics"
30
- METRICS_BUFFER_SIZE="${METRICS_BUFFER_SIZE:-1000}" # Ring buffer entries
31
- METRICS_DEBUG="${METRICS_DEBUG:-0}"
32
-
33
- # Current request context
34
- _METRICS_HOOK_NAME=""
35
- _METRICS_START_TIME=""
36
-
37
- # === Initialization ===
38
- metrics_init() {
39
- local hook_name="$1"
40
- _METRICS_HOOK_NAME="$hook_name"
41
- mkdir -p "$METRICS_DIR" 2>/dev/null || true
42
- }
43
-
44
- # === Timestamp ===
45
- _metrics_now_ms() {
46
- if command -v gdate &>/dev/null; then
47
- gdate +%s%3N
48
- elif date +%s%N &>/dev/null 2>&1; then
49
- echo $(($(date +%s%N) / 1000000))
50
- else
51
- echo "$(($(date +%s) * 1000))"
52
- fi
53
- }
54
-
55
- _metrics_now_sec() {
56
- date +%s
57
- }
58
-
59
- # === File Paths ===
60
- _metrics_buffer_file() {
61
- local hook_name="${1:-$_METRICS_HOOK_NAME}"
62
- echo "$METRICS_DIR/${hook_name}.buffer"
63
- }
64
-
65
- _metrics_stats_file() {
66
- local hook_name="${1:-$_METRICS_HOOK_NAME}"
67
- echo "$METRICS_DIR/${hook_name}.stats"
68
- }
69
-
70
- _metrics_hourly_file() {
71
- local hook_name="${1:-$_METRICS_HOOK_NAME}"
72
- local hour
73
- hour=$(date +%Y%m%d%H)
74
- echo "$METRICS_DIR/${hook_name}.hourly.${hour}"
75
- }
76
-
77
- # === Request Tracking ===
78
- metrics_start_request() {
79
- _METRICS_START_TIME=$(_metrics_now_ms)
80
- }
81
-
82
- metrics_end_request() {
83
- local status="${1:-success}" # success, failure, timeout, skipped
84
-
85
- [[ -z "$_METRICS_START_TIME" ]] && return
86
-
87
- local end_time
88
- end_time=$(_metrics_now_ms)
89
- local duration_ms=$((_METRICS_START_TIME > 0 ? end_time - _METRICS_START_TIME : 0))
90
-
91
- # Record to buffer
92
- _metrics_record_entry "$duration_ms" "$status"
93
-
94
- # Update running stats
95
- _metrics_update_stats "$duration_ms" "$status"
96
-
97
- _METRICS_START_TIME=""
98
- }
99
-
100
- # === Buffer Management (Ring Buffer) ===
101
- _metrics_record_entry() {
102
- local duration_ms="$1"
103
- local status="$2"
104
- local timestamp
105
- timestamp=$(_metrics_now_sec)
106
-
107
- local buffer_file
108
- buffer_file=$(_metrics_buffer_file)
109
-
110
- # Compact format: timestamp,duration_ms,status_code
111
- # Status codes: 0=success, 1=failure, 2=timeout, 3=skipped
112
- local status_code=0
113
- case "$status" in
114
- success) status_code=0 ;;
115
- failure) status_code=1 ;;
116
- timeout) status_code=2 ;;
117
- skipped) status_code=3 ;;
118
- esac
119
-
120
- echo "${timestamp},${duration_ms},${status_code}" >> "$buffer_file" 2>/dev/null || true
121
-
122
- # Trim buffer if too large
123
- if [[ -f "$buffer_file" ]]; then
124
- local line_count
125
- line_count=$(wc -l < "$buffer_file" 2>/dev/null | tr -d ' ')
126
- if [[ "$line_count" -gt "$METRICS_BUFFER_SIZE" ]]; then
127
- tail -$((METRICS_BUFFER_SIZE / 2)) "$buffer_file" > "${buffer_file}.tmp" 2>/dev/null && \
128
- mv "${buffer_file}.tmp" "$buffer_file" 2>/dev/null || true
129
- fi
130
- fi
131
- }
132
-
133
- # === Running Statistics ===
134
- _metrics_update_stats() {
135
- local duration_ms="$1"
136
- local status="$2"
137
-
138
- local stats_file
139
- stats_file=$(_metrics_stats_file)
140
-
141
- # Read current stats
142
- local total=0 success=0 failure=0 timeout=0 skipped=0
143
- local sum_duration=0 min_duration=999999 max_duration=0
144
-
145
- if [[ -f "$stats_file" ]]; then
146
- # Format: total,success,failure,timeout,skipped,sum_duration,min,max
147
- IFS=',' read -r total success failure timeout skipped sum_duration min_duration max_duration < "$stats_file" 2>/dev/null || true
148
- fi
149
-
150
- # Update counters
151
- total=$((total + 1))
152
- case "$status" in
153
- success) success=$((success + 1)) ;;
154
- failure) failure=$((failure + 1)) ;;
155
- timeout) timeout=$((timeout + 1)) ;;
156
- skipped) skipped=$((skipped + 1)) ;;
157
- esac
158
-
159
- # Update duration stats (only for non-skipped)
160
- if [[ "$status" != "skipped" ]]; then
161
- sum_duration=$((sum_duration + duration_ms))
162
- [[ "$duration_ms" -lt "$min_duration" ]] && min_duration=$duration_ms
163
- [[ "$duration_ms" -gt "$max_duration" ]] && max_duration=$duration_ms
164
- fi
165
-
166
- # Write updated stats
167
- echo "${total},${success},${failure},${timeout},${skipped},${sum_duration},${min_duration},${max_duration}" > "$stats_file" 2>/dev/null || true
168
- }
169
-
170
- # === Percentile Calculation ===
171
- _metrics_calculate_percentile() {
172
- local hook_name="$1"
173
- local percentile="$2" # 50, 95, 99
174
-
175
- local buffer_file
176
- buffer_file=$(_metrics_buffer_file "$hook_name")
177
-
178
- [[ ! -f "$buffer_file" ]] && echo "0" && return
179
-
180
- # Extract durations (second field) and sort
181
- local sorted_durations
182
- sorted_durations=$(cut -d',' -f2 "$buffer_file" 2>/dev/null | sort -n)
183
-
184
- local count
185
- count=$(echo "$sorted_durations" | wc -l | tr -d ' ')
186
-
187
- [[ "$count" -eq 0 ]] && echo "0" && return
188
-
189
- # Calculate index for percentile
190
- local index=$(( (count * percentile) / 100 ))
191
- [[ "$index" -lt 1 ]] && index=1
192
-
193
- # Get value at index
194
- echo "$sorted_durations" | sed -n "${index}p"
195
- }
196
-
197
- # === Public API ===
198
-
199
- # Get current metrics for a hook
200
- metrics_get() {
201
- local hook_name="${1:-$_METRICS_HOOK_NAME}"
202
- local stats_file
203
- stats_file=$(_metrics_stats_file "$hook_name")
204
-
205
- local total=0 success=0 failure=0 timeout=0 skipped=0
206
- local sum_duration=0 min_duration=0 max_duration=0
207
-
208
- if [[ -f "$stats_file" ]]; then
209
- IFS=',' read -r total success failure timeout skipped sum_duration min_duration max_duration < "$stats_file" 2>/dev/null || true
210
- fi
211
-
212
- # Calculate rates
213
- local success_rate=100
214
- local executed=$((total - skipped))
215
- if [[ "$executed" -gt 0 ]]; then
216
- success_rate=$(( (success * 100) / executed ))
217
- fi
218
-
219
- # Calculate average duration
220
- local avg_duration=0
221
- if [[ "$executed" -gt 0 ]]; then
222
- avg_duration=$((sum_duration / executed))
223
- fi
224
-
225
- # Get percentiles
226
- local p50 p95 p99
227
- p50=$(_metrics_calculate_percentile "$hook_name" 50)
228
- p95=$(_metrics_calculate_percentile "$hook_name" 95)
229
- p99=$(_metrics_calculate_percentile "$hook_name" 99)
230
-
231
- echo "{\"hook\":\"$hook_name\",\"total\":$total,\"success\":$success,\"failure\":$failure,\"timeout\":$timeout,\"skipped\":$skipped,\"success_rate\":$success_rate,\"avg_ms\":$avg_duration,\"min_ms\":$min_duration,\"max_ms\":$max_duration,\"p50_ms\":$p50,\"p95_ms\":$p95,\"p99_ms\":$p99}"
232
- }
233
-
234
- # Calculate health score (0-100)
235
- metrics_health_score() {
236
- local hook_name="${1:-$_METRICS_HOOK_NAME}"
237
-
238
- local stats_file
239
- stats_file=$(_metrics_stats_file "$hook_name")
240
-
241
- [[ ! -f "$stats_file" ]] && echo "100" && return
242
-
243
- local total success failure timeout skipped sum_duration min_duration max_duration
244
- IFS=',' read -r total success failure timeout skipped sum_duration min_duration max_duration < "$stats_file" 2>/dev/null || true
245
-
246
- local executed=$((total - skipped))
247
- [[ "$executed" -eq 0 ]] && echo "100" && return
248
-
249
- # Score components:
250
- # - Success rate: 60% weight
251
- # - No timeouts: 25% weight
252
- # - Low latency: 15% weight
253
-
254
- local success_rate=$(( (success * 100) / executed ))
255
- local timeout_rate=$(( (timeout * 100) / executed ))
256
- local avg_duration=$((sum_duration / executed))
257
-
258
- # Success rate score (0-60)
259
- local success_score=$((success_rate * 60 / 100))
260
-
261
- # Timeout score (0-25, penalize timeouts heavily)
262
- local timeout_score=$((25 - (timeout_rate * 25 / 100)))
263
- [[ "$timeout_score" -lt 0 ]] && timeout_score=0
264
-
265
- # Latency score (0-15, based on avg duration)
266
- # <100ms = 15, 100-500ms = 10, 500-1000ms = 5, >1000ms = 0
267
- local latency_score=0
268
- if [[ "$avg_duration" -lt 100 ]]; then
269
- latency_score=15
270
- elif [[ "$avg_duration" -lt 500 ]]; then
271
- latency_score=10
272
- elif [[ "$avg_duration" -lt 1000 ]]; then
273
- latency_score=5
274
- fi
275
-
276
- local total_score=$((success_score + timeout_score + latency_score))
277
- echo "$total_score"
278
- }
279
-
280
- # Get all hooks metrics summary
281
- metrics_get_all() {
282
- local result="["
283
- local first=true
284
-
285
- for stats_file in "$METRICS_DIR"/*.stats; do
286
- [[ ! -f "$stats_file" ]] && continue
287
-
288
- local hook_name
289
- hook_name=$(basename "$stats_file" .stats)
290
-
291
- if [[ "$first" == "true" ]]; then
292
- first=false
293
- else
294
- result="$result,"
295
- fi
296
-
297
- result="$result$(metrics_get "$hook_name")"
298
- done
299
-
300
- result="$result]"
301
- echo "$result"
302
- }
303
-
304
- # Reset metrics for a hook
305
- metrics_reset() {
306
- local hook_name="${1:-$_METRICS_HOOK_NAME}"
307
-
308
- rm -f "$METRICS_DIR/${hook_name}.buffer" 2>/dev/null || true
309
- rm -f "$METRICS_DIR/${hook_name}.stats" 2>/dev/null || true
310
- rm -f "$METRICS_DIR/${hook_name}.hourly."* 2>/dev/null || true
311
- }
312
-
313
- # Get system-wide health summary
314
- metrics_system_health() {
315
- local total_hooks=0
316
- local healthy_hooks=0
317
- local degraded_hooks=0
318
- local unhealthy_hooks=0
319
-
320
- for stats_file in "$METRICS_DIR"/*.stats; do
321
- [[ ! -f "$stats_file" ]] && continue
322
-
323
- local hook_name
324
- hook_name=$(basename "$stats_file" .stats)
325
- local health_score
326
- health_score=$(metrics_health_score "$hook_name")
327
-
328
- total_hooks=$((total_hooks + 1))
329
-
330
- if [[ "$health_score" -ge 80 ]]; then
331
- healthy_hooks=$((healthy_hooks + 1))
332
- elif [[ "$health_score" -ge 50 ]]; then
333
- degraded_hooks=$((degraded_hooks + 1))
334
- else
335
- unhealthy_hooks=$((unhealthy_hooks + 1))
336
- fi
337
- done
338
-
339
- local overall_health="healthy"
340
- if [[ "$unhealthy_hooks" -gt 0 ]]; then
341
- overall_health="unhealthy"
342
- elif [[ "$degraded_hooks" -gt 0 ]]; then
343
- overall_health="degraded"
344
- fi
345
-
346
- echo "{\"status\":\"$overall_health\",\"total_hooks\":$total_hooks,\"healthy\":$healthy_hooks,\"degraded\":$degraded_hooks,\"unhealthy\":$unhealthy_hooks}"
347
- }
@@ -1,216 +0,0 @@
1
- #!/bin/bash
2
- # semaphore.sh - File-based semaphore for limiting concurrent hook execution
3
- #
4
- # PROBLEM SOLVED:
5
- # Process storms occur when many hooks spawn simultaneously, overwhelming the system.
6
- # Instead of detecting storms and blocking EVERYTHING (current behavior), this semaphore
7
- # limits concurrency properly - excess requests wait or timeout gracefully.
8
- #
9
- # USAGE:
10
- # source semaphore.sh
11
- # if acquire_semaphore "hook-name" 10 5000; then
12
- # # Do work
13
- # release_semaphore "hook-name"
14
- # else
15
- # # Timeout - return safe default
16
- # fi
17
- #
18
- # DESIGN:
19
- # - Uses file-based locks (portable, no dependencies)
20
- # - Configurable max concurrent slots
21
- # - Configurable timeout with exponential backoff
22
- # - Auto-cleanup of stale locks (older than 30s)
23
- # - Request queuing with FIFO ordering
24
- #
25
- # v1.0.0 - Initial implementation (2025-12-17)
26
-
27
- set -o pipefail
28
-
29
- # === Configuration ===
30
- SEMAPHORE_DIR="${SPECWEAVE_STATE_DIR:-.specweave/state}/semaphores"
31
- SEMAPHORE_MAX_AGE_SECONDS=30 # Stale lock threshold
32
- SEMAPHORE_DEBUG="${SEMAPHORE_DEBUG:-0}"
33
-
34
- # === Initialization ===
35
- _init_semaphore_dir() {
36
- mkdir -p "$SEMAPHORE_DIR" 2>/dev/null || true
37
- }
38
-
39
- # === Logging ===
40
- _sem_log() {
41
- [[ "$SEMAPHORE_DEBUG" == "1" ]] && echo "[SEM $(date +%H:%M:%S.%3N)] $*" >&2
42
- }
43
-
44
- # === Cleanup stale locks ===
45
- # Locks older than SEMAPHORE_MAX_AGE_SECONDS are considered abandoned
46
- cleanup_stale_locks() {
47
- local name="$1"
48
- local lock_dir="$SEMAPHORE_DIR/$name"
49
-
50
- [[ ! -d "$lock_dir" ]] && return 0
51
-
52
- local now
53
- now=$(date +%s)
54
-
55
- for lock_file in "$lock_dir"/*.lock; do
56
- [[ ! -f "$lock_file" ]] && continue
57
-
58
- local mtime
59
- if [[ "$(uname)" == "Darwin" ]]; then
60
- mtime=$(stat -f %m "$lock_file" 2>/dev/null || echo "0")
61
- else
62
- mtime=$(stat -c %Y "$lock_file" 2>/dev/null || echo "0")
63
- fi
64
-
65
- local age=$((now - mtime))
66
- if [[ "$age" -gt "$SEMAPHORE_MAX_AGE_SECONDS" ]]; then
67
- _sem_log "Cleaning stale lock: $lock_file (age: ${age}s)"
68
- rm -f "$lock_file" 2>/dev/null || true
69
- fi
70
- done
71
- }
72
-
73
- # === Count active slots ===
74
- count_active_slots() {
75
- local name="$1"
76
- local lock_dir="$SEMAPHORE_DIR/$name"
77
-
78
- [[ ! -d "$lock_dir" ]] && echo "0" && return
79
-
80
- local count=0
81
- for lock_file in "$lock_dir"/*.lock; do
82
- [[ -f "$lock_file" ]] && count=$((count + 1))
83
- done
84
-
85
- echo "$count"
86
- }
87
-
88
- # === Acquire semaphore slot ===
89
- # Args: name, max_slots, timeout_ms
90
- # Returns: 0 if acquired, 1 if timeout
91
- acquire_semaphore() {
92
- local name="${1:-default}"
93
- local max_slots="${2:-10}"
94
- local timeout_ms="${3:-5000}"
95
-
96
- _init_semaphore_dir
97
-
98
- local lock_dir="$SEMAPHORE_DIR/$name"
99
- mkdir -p "$lock_dir" 2>/dev/null || true
100
-
101
- # Generate unique slot ID (use random instead of %N which doesn't work on macOS)
102
- local slot_id="$$-$RANDOM$RANDOM"
103
- local lock_file="$lock_dir/${slot_id}.lock"
104
-
105
- # Store slot ID for release
106
- export _SEMAPHORE_SLOT_ID="$slot_id"
107
- export _SEMAPHORE_NAME="$name"
108
- export _SEMAPHORE_LOCK_FILE="$lock_file"
109
-
110
- local start_time
111
- # macOS doesn't support %N, use seconds * 1000 as approximation
112
- if command -v gdate &>/dev/null; then
113
- start_time=$(gdate +%s%3N)
114
- else
115
- start_time=$(($(date +%s) * 1000))
116
- fi
117
-
118
- local attempt=0
119
- local backoff_ms=10 # Start with 10ms backoff
120
- local max_backoff_ms=200
121
-
122
- while true; do
123
- # Cleanup stale locks periodically (every 5 attempts)
124
- [[ $((attempt % 5)) -eq 0 ]] && cleanup_stale_locks "$name"
125
-
126
- local current_slots
127
- current_slots=$(count_active_slots "$name")
128
-
129
- if [[ "$current_slots" -lt "$max_slots" ]]; then
130
- # Try to acquire slot atomically
131
- if (set -o noclobber; echo "$$" > "$lock_file") 2>/dev/null; then
132
- _sem_log "Acquired slot $slot_id for $name (slots: $((current_slots + 1))/$max_slots)"
133
- return 0
134
- fi
135
- fi
136
-
137
- # Check timeout
138
- local now
139
- if command -v gdate &>/dev/null; then
140
- now=$(gdate +%s%3N)
141
- else
142
- now=$(($(date +%s) * 1000))
143
- fi
144
- local elapsed=$((now - start_time))
145
-
146
- if [[ "$elapsed" -ge "$timeout_ms" ]]; then
147
- _sem_log "Timeout acquiring $name after ${elapsed}ms (slots: $current_slots/$max_slots)"
148
- return 1
149
- fi
150
-
151
- # Exponential backoff with jitter
152
- local jitter=$((RANDOM % 20))
153
- local sleep_ms=$((backoff_ms + jitter))
154
-
155
- _sem_log "Waiting for slot $name (attempt $attempt, backoff ${sleep_ms}ms, slots: $current_slots/$max_slots)"
156
-
157
- # Sleep (convert ms to fractional seconds)
158
- sleep "0.$(printf '%03d' $sleep_ms)" 2>/dev/null || sleep 0.1
159
-
160
- # Increase backoff (exponential with cap)
161
- backoff_ms=$((backoff_ms * 2))
162
- [[ "$backoff_ms" -gt "$max_backoff_ms" ]] && backoff_ms=$max_backoff_ms
163
-
164
- attempt=$((attempt + 1))
165
- done
166
- }
167
-
168
- # === Release semaphore slot ===
169
- release_semaphore() {
170
- local name="${1:-$_SEMAPHORE_NAME}"
171
- local lock_file="${_SEMAPHORE_LOCK_FILE}"
172
-
173
- if [[ -n "$lock_file" ]] && [[ -f "$lock_file" ]]; then
174
- rm -f "$lock_file" 2>/dev/null || true
175
- _sem_log "Released slot for $name"
176
- fi
177
-
178
- unset _SEMAPHORE_SLOT_ID
179
- unset _SEMAPHORE_NAME
180
- unset _SEMAPHORE_LOCK_FILE
181
- }
182
-
183
- # === Get semaphore status ===
184
- get_semaphore_status() {
185
- local name="${1:-default}"
186
- local max_slots="${2:-10}"
187
-
188
- _init_semaphore_dir
189
- cleanup_stale_locks "$name"
190
-
191
- local active
192
- active=$(count_active_slots "$name")
193
-
194
- echo "{\"name\":\"$name\",\"active\":$active,\"max\":$max_slots,\"available\":$((max_slots - active))}"
195
- }
196
-
197
- # === Force release all slots (emergency) ===
198
- force_release_all() {
199
- local name="${1:-default}"
200
- local lock_dir="$SEMAPHORE_DIR/$name"
201
-
202
- if [[ -d "$lock_dir" ]]; then
203
- rm -f "$lock_dir"/*.lock 2>/dev/null || true
204
- _sem_log "Force released all slots for $name"
205
- fi
206
- }
207
-
208
- # === Trap handler for automatic cleanup ===
209
- _semaphore_cleanup_trap() {
210
- release_semaphore 2>/dev/null || true
211
- }
212
-
213
- # Register cleanup trap if sourced
214
- if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then
215
- trap _semaphore_cleanup_trap EXIT INT TERM
216
- fi