ruby_reactor 0.5.4 → 0.6.0

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/speckit-agent-context-update/SKILL.md +32 -0
  3. data/.claude/skills/speckit-analyze/SKILL.md +262 -0
  4. data/.claude/skills/speckit-checklist/SKILL.md +374 -0
  5. data/.claude/skills/speckit-clarify/SKILL.md +286 -0
  6. data/.claude/skills/speckit-constitution/SKILL.md +157 -0
  7. data/.claude/skills/speckit-converge/SKILL.md +277 -0
  8. data/.claude/skills/speckit-implement/SKILL.md +224 -0
  9. data/.claude/skills/speckit-plan/SKILL.md +171 -0
  10. data/.claude/skills/speckit-specify/SKILL.md +346 -0
  11. data/.claude/skills/speckit-tasks/SKILL.md +215 -0
  12. data/.claude/skills/speckit-taskstoissues/SKILL.md +110 -0
  13. data/.release-please-manifest.json +1 -1
  14. data/.specify/extensions/.registry +19 -0
  15. data/.specify/extensions/agent-context/README.md +66 -0
  16. data/.specify/extensions/agent-context/agent-context-config.yml +5 -0
  17. data/.specify/extensions/agent-context/commands/speckit.agent-context.update.md +27 -0
  18. data/.specify/extensions/agent-context/extension.yml +34 -0
  19. data/.specify/extensions/agent-context/scripts/bash/update-agent-context.sh +282 -0
  20. data/.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +353 -0
  21. data/.specify/extensions.yml +23 -0
  22. data/.specify/feature.json +3 -0
  23. data/.specify/init-options.json +9 -0
  24. data/.specify/integration.json +15 -0
  25. data/.specify/integrations/claude.manifest.json +17 -0
  26. data/.specify/integrations/speckit.manifest.json +17 -0
  27. data/.specify/memory/constitution.md +134 -0
  28. data/.specify/scripts/bash/check-prerequisites.sh +189 -0
  29. data/.specify/scripts/bash/common.sh +619 -0
  30. data/.specify/scripts/bash/create-new-feature.sh +299 -0
  31. data/.specify/scripts/bash/setup-plan.sh +84 -0
  32. data/.specify/scripts/bash/setup-tasks.sh +91 -0
  33. data/.specify/templates/checklist-template.md +40 -0
  34. data/.specify/templates/constitution-template.md +50 -0
  35. data/.specify/templates/plan-template.md +113 -0
  36. data/.specify/templates/spec-template.md +131 -0
  37. data/.specify/templates/tasks-template.md +252 -0
  38. data/.specify/workflows/speckit/workflow.yml +77 -0
  39. data/.specify/workflows/workflow-registry.json +13 -0
  40. data/CHANGELOG.md +7 -0
  41. data/README.md +51 -24
  42. data/lib/ruby_reactor/adapters/active_job/compat.rb +24 -0
  43. data/lib/ruby_reactor/adapters/active_job/map_collector_worker.rb +19 -0
  44. data/lib/ruby_reactor/adapters/active_job/map_element_worker.rb +19 -0
  45. data/lib/ruby_reactor/adapters/active_job/router.rb +91 -0
  46. data/lib/ruby_reactor/adapters/active_job/sweeper_worker.rb +16 -0
  47. data/lib/ruby_reactor/adapters/active_job/worker.rb +24 -0
  48. data/lib/ruby_reactor/adapters/sidekiq/map_collector_worker.rb +15 -0
  49. data/lib/ruby_reactor/adapters/sidekiq/map_element_worker.rb +15 -0
  50. data/lib/ruby_reactor/adapters/sidekiq/router.rb +91 -0
  51. data/lib/ruby_reactor/adapters/sidekiq/sweeper_worker.rb +19 -0
  52. data/lib/ruby_reactor/adapters/sidekiq/worker.rb +25 -0
  53. data/lib/ruby_reactor/configuration.rb +24 -4
  54. data/lib/ruby_reactor/map/element_executor.rb +1 -1
  55. data/lib/ruby_reactor/rspec/active_job_helpers.rb +52 -0
  56. data/lib/ruby_reactor/rspec/async_test_helpers.rb +41 -0
  57. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +3 -3
  58. data/lib/ruby_reactor/rspec/test_subject.rb +11 -7
  59. data/lib/ruby_reactor/rspec.rb +4 -0
  60. data/lib/ruby_reactor/sweeper_job.rb +70 -0
  61. data/lib/ruby_reactor/version.rb +1 -1
  62. data/lib/ruby_reactor/worker.rb +226 -0
  63. data/lib/ruby_reactor.rb +40 -1
  64. data/specs/active_job.md +259 -0
  65. metadata +54 -6
  66. data/lib/ruby_reactor/sidekiq_adapter.rb +0 -87
  67. data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +0 -13
  68. data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +0 -13
  69. data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +0 -73
  70. data/lib/ruby_reactor/sidekiq_workers/worker.rb +0 -222
@@ -0,0 +1,619 @@
1
+ #!/usr/bin/env bash
2
+ # Common functions and variables for all scripts
3
+
4
+ # Find repository root by searching upward for .specify directory
5
+ # This is the primary marker for spec-kit projects
6
+ find_specify_root() {
7
+ local dir="${1:-$(pwd)}"
8
+ # Normalize to absolute path to prevent infinite loop with relative paths
9
+ # Use -- to handle paths starting with - (e.g., -P, -L)
10
+ dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1
11
+ local prev_dir=""
12
+ while true; do
13
+ if [ -d "$dir/.specify" ]; then
14
+ echo "$dir"
15
+ return 0
16
+ fi
17
+ # Stop if we've reached filesystem root or dirname stops changing
18
+ if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then
19
+ break
20
+ fi
21
+ prev_dir="$dir"
22
+ dir="$(dirname "$dir")"
23
+ done
24
+ return 1
25
+ }
26
+
27
+ # Resolve an explicit SPECIFY_INIT_DIR project override (the directory that
28
+ # *contains* .specify/), for non-interactive / CI use — e.g. running a Spec Kit
29
+ # command against a member project from a monorepo root without cd.
30
+ #
31
+ # Precondition: SPECIFY_INIT_DIR is non-empty. Echoes the validated absolute
32
+ # project root, or prints an error and returns 1. Strict by design: the path
33
+ # must exist and contain .specify/, with no silent fallback to cwd or the
34
+ # script-location default (which would silently write to the wrong project).
35
+ #
36
+ # This is the single resolver: bundled extensions inherit it by sourcing core
37
+ # (e.g. the git extension's create-new-feature-branch) rather than duplicating it.
38
+ resolve_specify_init_dir() {
39
+ local init_root
40
+ # Normalize: relative paths resolve against $(pwd); a trailing slash collapses.
41
+ # CDPATH="" so a relative value cannot be resolved against the caller's CDPATH
42
+ # (which would also echo to stdout and corrupt the captured path).
43
+ if ! init_root="$(CDPATH="" cd -- "$SPECIFY_INIT_DIR" 2>/dev/null && pwd)"; then
44
+ echo "ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $SPECIFY_INIT_DIR" >&2
45
+ return 1
46
+ fi
47
+ if [[ ! -d "$init_root/.specify" ]]; then
48
+ echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $init_root" >&2
49
+ return 1
50
+ fi
51
+ printf '%s\n' "$init_root"
52
+ }
53
+
54
+ # Get repository root, prioritizing .specify directory
55
+ # This prevents using a parent repository when spec-kit is initialized in a subdirectory
56
+ get_repo_root() {
57
+ # Explicit project override wins (see resolve_specify_init_dir).
58
+ if [[ -n "${SPECIFY_INIT_DIR:-}" ]]; then
59
+ resolve_specify_init_dir
60
+ return
61
+ fi
62
+
63
+ # First, look for .specify directory (spec-kit's own marker)
64
+ local specify_root
65
+ if specify_root=$(find_specify_root); then
66
+ echo "$specify_root"
67
+ return
68
+ fi
69
+
70
+ # Final fallback to script location
71
+ local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
72
+ (cd "$script_dir/../../.." && pwd)
73
+ }
74
+
75
+ # Get current feature name from explicit state only.
76
+ # Returns the feature identifier or empty string if none is set.
77
+ # Feature state is set by SPECIFY_FEATURE (from create-new-feature or
78
+ # the git extension) or implicitly via .specify/feature.json.
79
+ get_current_branch() {
80
+ if [[ -n "${SPECIFY_FEATURE:-}" ]]; then
81
+ echo "$SPECIFY_FEATURE"
82
+ return
83
+ fi
84
+
85
+ # No explicit feature set — caller must handle this via feature.json
86
+ # in get_feature_paths(). Return empty to signal "unknown".
87
+ echo ""
88
+ }
89
+
90
+ # Safely read .specify/feature.json's "feature_directory" value.
91
+ # Prints the raw value (possibly relative) to stdout, or empty string if the file
92
+ # is missing, unparseable, or does not contain the key. Always returns 0 so callers
93
+ # under `set -e` cannot be aborted by parser failure.
94
+ # Parser order mirrors the historical get_feature_paths behavior: jq -> python3 -> grep/sed.
95
+ read_feature_json_feature_directory() {
96
+ local repo_root="$1"
97
+ local fj="$repo_root/.specify/feature.json"
98
+ [[ -f "$fj" ]] || { printf '%s' ''; return 0; }
99
+
100
+ local _fd=''
101
+ if command -v jq >/dev/null 2>&1; then
102
+ if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then
103
+ _fd=''
104
+ fi
105
+ elif command -v python3 >/dev/null 2>&1; then
106
+ # Use Python so pretty-printed/multi-line JSON still parses correctly.
107
+ if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then
108
+ _fd=''
109
+ fi
110
+ else
111
+ # Last-resort single-line grep/sed fallback. The `|| true` guards against
112
+ # grep returning 1 (no match) aborting under `set -e` / `pipefail`.
113
+ _fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \
114
+ | head -n 1 \
115
+ | sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' )
116
+ fi
117
+
118
+ printf '%s' "$_fd"
119
+ return 0
120
+ }
121
+
122
+ # Persist a feature_directory value to .specify/feature.json.
123
+ # Writes only when the file is missing or the value differs from what's stored.
124
+ # Accepts the raw (possibly relative) path — callers should pass the original
125
+ # user-supplied value, not the normalized absolute path.
126
+ _persist_feature_json() {
127
+ local repo_root="$1"
128
+ local feature_dir_value="$2"
129
+ local fj="$repo_root/.specify/feature.json"
130
+
131
+ # Strip repo_root prefix if the value is absolute and under repo_root
132
+ if [[ "$feature_dir_value" == "$repo_root/"* ]]; then
133
+ feature_dir_value="${feature_dir_value#"$repo_root/"}"
134
+ fi
135
+
136
+ # Read current value (if any) and skip write when unchanged
137
+ local current_val
138
+ current_val=$(read_feature_json_feature_directory "$repo_root")
139
+ if [[ "$current_val" == "$feature_dir_value" ]]; then
140
+ return 0
141
+ fi
142
+
143
+ # Ensure .specify/ directory exists
144
+ mkdir -p "$repo_root/.specify"
145
+
146
+ # Write feature.json — prefer jq for safe JSON, fall back to printf
147
+ if command -v jq >/dev/null 2>&1; then
148
+ jq -cn --arg fd "$feature_dir_value" '{feature_directory:$fd}' > "$fj"
149
+ else
150
+ printf '{"feature_directory":"%s"}\n' "$(json_escape "$feature_dir_value")" > "$fj"
151
+ fi
152
+ }
153
+
154
+ get_feature_paths() {
155
+ # Split decl/assignment so a SPECIFY_INIT_DIR validation failure in
156
+ # get_repo_root propagates as a hard error instead of being masked by `local`.
157
+ local repo_root
158
+ repo_root=$(get_repo_root) || return 1
159
+ local current_branch
160
+ current_branch=$(get_current_branch)
161
+
162
+ # Resolve feature directory. Priority:
163
+ # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
164
+ # 2. .specify/feature.json "feature_directory" key (persisted by specify command)
165
+ # 3. Error — no feature context available
166
+ local feature_dir
167
+ if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
168
+ feature_dir="$SPECIFY_FEATURE_DIRECTORY"
169
+ # Normalize relative paths to absolute under repo root
170
+ [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
171
+ # Persist to feature.json so future sessions without the env var still work
172
+ _persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY"
173
+ elif [[ -f "$repo_root/.specify/feature.json" ]]; then
174
+ local _fd
175
+ _fd=$(read_feature_json_feature_directory "$repo_root")
176
+ if [[ -n "$_fd" ]]; then
177
+ feature_dir="$_fd"
178
+ # Normalize relative paths to absolute under repo root
179
+ [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
180
+ else
181
+ echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory." >&2
182
+ return 1
183
+ fi
184
+ else
185
+ echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json." >&2
186
+ return 1
187
+ fi
188
+
189
+ # Use printf '%q' to safely quote values, preventing shell injection
190
+ # via crafted branch names or paths containing special characters
191
+ printf 'REPO_ROOT=%q\n' "$repo_root"
192
+ printf 'CURRENT_BRANCH=%q\n' "$current_branch"
193
+ printf 'FEATURE_DIR=%q\n' "$feature_dir"
194
+ printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md"
195
+ printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md"
196
+ printf 'TASKS=%q\n' "$feature_dir/tasks.md"
197
+ printf 'RESEARCH=%q\n' "$feature_dir/research.md"
198
+ printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md"
199
+ printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md"
200
+ printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts"
201
+ }
202
+
203
+ # Check if jq is available for safe JSON construction
204
+ has_jq() {
205
+ command -v jq >/dev/null 2>&1
206
+ }
207
+
208
+ get_invoke_separator() {
209
+ local repo_root="${1:-$(get_repo_root)}"
210
+ if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then
211
+ printf '%s\n' "$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE"
212
+ return 0
213
+ fi
214
+
215
+ local integration_json="$repo_root/.specify/integration.json"
216
+ local separator="."
217
+ local parsed_with_jq=0
218
+
219
+ if [[ -f "$integration_json" ]]; then
220
+ if command -v jq >/dev/null 2>&1; then
221
+ local jq_separator
222
+ if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then
223
+ parsed_with_jq=1
224
+ case "$jq_separator" in
225
+ "."|"-") separator="$jq_separator" ;;
226
+ esac
227
+ fi
228
+ fi
229
+
230
+ if [[ "$parsed_with_jq" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then
231
+ if separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null
232
+ import json
233
+ import sys
234
+
235
+ try:
236
+ with open(sys.argv[1], encoding="utf-8") as fh:
237
+ state = json.load(fh)
238
+ key = state.get("default_integration") or state.get("integration") or ""
239
+ settings = state.get("integration_settings")
240
+ separator = "."
241
+ if isinstance(key, str) and isinstance(settings, dict):
242
+ entry = settings.get(key)
243
+ if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
244
+ separator = entry["invoke_separator"]
245
+ print(separator)
246
+ except Exception:
247
+ print(".")
248
+ PY
249
+ ); then
250
+ case "$separator" in
251
+ "."|"-") ;;
252
+ *) separator="." ;;
253
+ esac
254
+ else
255
+ separator="."
256
+ fi
257
+ fi
258
+ fi
259
+
260
+ _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
261
+ _SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator"
262
+ printf '%s\n' "$separator"
263
+ }
264
+
265
+ format_speckit_command() {
266
+ local command_name="$1"
267
+ local repo_root="${2:-$(get_repo_root)}"
268
+ local separator
269
+ if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then
270
+ separator="$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE"
271
+ else
272
+ separator=$(get_invoke_separator "$repo_root")
273
+ _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
274
+ _SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator"
275
+ fi
276
+
277
+ command_name="${command_name#/}"
278
+ command_name="${command_name#speckit.}"
279
+ command_name="${command_name#speckit-}"
280
+ command_name="${command_name//./$separator}"
281
+
282
+ printf '/speckit%s%s\n' "$separator" "$command_name"
283
+ }
284
+
285
+ # Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
286
+ # Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259).
287
+ json_escape() {
288
+ local s="$1"
289
+ s="${s//\\/\\\\}"
290
+ s="${s//\"/\\\"}"
291
+ s="${s//$'\n'/\\n}"
292
+ s="${s//$'\t'/\\t}"
293
+ s="${s//$'\r'/\\r}"
294
+ s="${s//$'\b'/\\b}"
295
+ s="${s//$'\f'/\\f}"
296
+ # Escape any remaining U+0001-U+001F control characters as \uXXXX.
297
+ # (U+0000/NUL cannot appear in bash strings and is excluded.)
298
+ # LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes,
299
+ # so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact.
300
+ local LC_ALL=C
301
+ local i char code
302
+ for (( i=0; i<${#s}; i++ )); do
303
+ char="${s:$i:1}"
304
+ printf -v code '%d' "'$char" 2>/dev/null || code=256
305
+ if (( code >= 1 && code <= 31 )); then
306
+ printf '\\u%04x' "$code"
307
+ else
308
+ printf '%s' "$char"
309
+ fi
310
+ done
311
+ }
312
+
313
+ check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; }
314
+ check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; }
315
+
316
+ # Resolve a template name to a file path using the priority stack:
317
+ # 1. .specify/templates/overrides/
318
+ # 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
319
+ # 3. .specify/extensions/<ext-id>/templates/
320
+ # 4. .specify/templates/ (core)
321
+ resolve_template() {
322
+ local template_name="$1"
323
+ local repo_root="$2"
324
+ local base="$repo_root/.specify/templates"
325
+
326
+ # Priority 1: Project overrides
327
+ local override="$base/overrides/${template_name}.md"
328
+ [ -f "$override" ] && echo "$override" && return 0
329
+
330
+ # Priority 2: Installed presets (sorted by priority from .registry)
331
+ local presets_dir="$repo_root/.specify/presets"
332
+ if [ -d "$presets_dir" ]; then
333
+ local registry_file="$presets_dir/.registry"
334
+ if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
335
+ # Read preset IDs sorted by priority (lower number = higher precedence).
336
+ # The python3 call is wrapped in an if-condition so that set -e does not
337
+ # abort the function when python3 exits non-zero (e.g. invalid JSON).
338
+ local sorted_presets=""
339
+ if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
340
+ import json, sys, os
341
+ try:
342
+ with open(os.environ['SPECKIT_REGISTRY']) as f:
343
+ data = json.load(f)
344
+ presets = data.get('presets', {})
345
+ for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
346
+ if isinstance(meta, dict) and meta.get('enabled', True) is not False:
347
+ print(pid)
348
+ except Exception:
349
+ sys.exit(1)
350
+ " 2>/dev/null); then
351
+ if [ -n "$sorted_presets" ]; then
352
+ # python3 succeeded and returned preset IDs — search in priority order
353
+ while IFS= read -r preset_id; do
354
+ local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
355
+ [ -f "$candidate" ] && echo "$candidate" && return 0
356
+ done <<< "$sorted_presets"
357
+ fi
358
+ # python3 succeeded but registry has no presets — nothing to search
359
+ else
360
+ # python3 failed (missing, or registry parse error) — fall back to unordered directory scan
361
+ for preset in "$presets_dir"/*/; do
362
+ [ -d "$preset" ] || continue
363
+ local candidate="$preset/templates/${template_name}.md"
364
+ [ -f "$candidate" ] && echo "$candidate" && return 0
365
+ done
366
+ fi
367
+ else
368
+ # Fallback: alphabetical directory order (no python3 available)
369
+ for preset in "$presets_dir"/*/; do
370
+ [ -d "$preset" ] || continue
371
+ local candidate="$preset/templates/${template_name}.md"
372
+ [ -f "$candidate" ] && echo "$candidate" && return 0
373
+ done
374
+ fi
375
+ fi
376
+
377
+ # Priority 3: Extension-provided templates
378
+ local ext_dir="$repo_root/.specify/extensions"
379
+ if [ -d "$ext_dir" ]; then
380
+ for ext in "$ext_dir"/*/; do
381
+ [ -d "$ext" ] || continue
382
+ # Skip hidden directories (e.g. .backup, .cache)
383
+ case "$(basename "$ext")" in .*) continue;; esac
384
+ local candidate="$ext/templates/${template_name}.md"
385
+ [ -f "$candidate" ] && echo "$candidate" && return 0
386
+ done
387
+ fi
388
+
389
+ # Priority 4: Core templates
390
+ local core="$base/${template_name}.md"
391
+ [ -f "$core" ] && echo "$core" && return 0
392
+
393
+ # Template not found in any location.
394
+ # Return 1 so callers can distinguish "not found" from "found".
395
+ # Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true
396
+ return 1
397
+ }
398
+
399
+ # Resolve a template name to composed content using composition strategies.
400
+ # Reads strategy metadata from preset manifests and composes content
401
+ # from multiple layers using prepend, append, or wrap strategies.
402
+ #
403
+ # Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT")
404
+ # Returns composed content string on stdout; exit code 1 if not found.
405
+ resolve_template_content() {
406
+ local template_name="$1"
407
+ local repo_root="$2"
408
+ local base="$repo_root/.specify/templates"
409
+
410
+ # Collect all layers (highest priority first)
411
+ local -a layer_paths=()
412
+ local -a layer_strategies=()
413
+
414
+ # Priority 1: Project overrides (always "replace")
415
+ local override="$base/overrides/${template_name}.md"
416
+ if [ -f "$override" ]; then
417
+ layer_paths+=("$override")
418
+ layer_strategies+=("replace")
419
+ fi
420
+
421
+ # Priority 2: Installed presets (sorted by priority from .registry)
422
+ local presets_dir="$repo_root/.specify/presets"
423
+ if [ -d "$presets_dir" ]; then
424
+ local registry_file="$presets_dir/.registry"
425
+ local sorted_presets=""
426
+ if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
427
+ if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
428
+ import json, sys, os
429
+ try:
430
+ with open(os.environ['SPECKIT_REGISTRY']) as f:
431
+ data = json.load(f)
432
+ presets = data.get('presets', {})
433
+ for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
434
+ if isinstance(meta, dict) and meta.get('enabled', True) is not False:
435
+ print(pid)
436
+ except Exception:
437
+ sys.exit(1)
438
+ " 2>/dev/null); then
439
+ if [ -n "$sorted_presets" ]; then
440
+ local yaml_warned=false
441
+ while IFS= read -r preset_id; do
442
+ # Read strategy and file path from preset manifest
443
+ local strategy="replace"
444
+ local manifest_file=""
445
+ local manifest="$presets_dir/$preset_id/preset.yml"
446
+ if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then
447
+ # Requires PyYAML; falls back to replace/convention if unavailable
448
+ local result
449
+ local py_stderr
450
+ py_stderr=$(mktemp)
451
+ result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c "
452
+ import sys, os
453
+ try:
454
+ import yaml
455
+ except ImportError:
456
+ print('yaml_missing', file=sys.stderr)
457
+ print('replace\t')
458
+ sys.exit(0)
459
+ try:
460
+ with open(os.environ['SPECKIT_MANIFEST']) as f:
461
+ data = yaml.safe_load(f)
462
+ for t in data.get('provides', {}).get('templates', []):
463
+ if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
464
+ print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
465
+ sys.exit(0)
466
+ print('replace\t')
467
+ except Exception:
468
+ print('replace\t')
469
+ " 2>"$py_stderr")
470
+ local parse_status=$?
471
+ if [ $parse_status -eq 0 ] && [ -n "$result" ]; then
472
+ IFS=$'\t' read -r strategy manifest_file <<< "$result"
473
+ strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]')
474
+ fi
475
+ if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then
476
+ echo "Warning: PyYAML not available; composition strategies may be ignored" >&2
477
+ yaml_warned=true
478
+ fi
479
+ rm -f "$py_stderr"
480
+ fi
481
+ # Try manifest file path first, then convention path
482
+ local candidate=""
483
+ if [ -n "$manifest_file" ]; then
484
+ # Reject absolute paths and parent traversal
485
+ case "$manifest_file" in
486
+ /*|*../*|../*) manifest_file="" ;;
487
+ esac
488
+ fi
489
+ if [ -n "$manifest_file" ]; then
490
+ local mf="$presets_dir/$preset_id/$manifest_file"
491
+ [ -f "$mf" ] && candidate="$mf"
492
+ fi
493
+ if [ -z "$candidate" ]; then
494
+ local cf="$presets_dir/$preset_id/templates/${template_name}.md"
495
+ [ -f "$cf" ] && candidate="$cf"
496
+ fi
497
+ if [ -n "$candidate" ]; then
498
+ layer_paths+=("$candidate")
499
+ layer_strategies+=("$strategy")
500
+ fi
501
+ done <<< "$sorted_presets"
502
+ fi
503
+ else
504
+ # python3 failed — fall back to unordered directory scan (replace only)
505
+ for preset in "$presets_dir"/*/; do
506
+ [ -d "$preset" ] || continue
507
+ local candidate="$preset/templates/${template_name}.md"
508
+ if [ -f "$candidate" ]; then
509
+ layer_paths+=("$candidate")
510
+ layer_strategies+=("replace")
511
+ fi
512
+ done
513
+ fi
514
+ else
515
+ # No python3 or registry — fall back to unordered directory scan (replace only)
516
+ for preset in "$presets_dir"/*/; do
517
+ [ -d "$preset" ] || continue
518
+ local candidate="$preset/templates/${template_name}.md"
519
+ if [ -f "$candidate" ]; then
520
+ layer_paths+=("$candidate")
521
+ layer_strategies+=("replace")
522
+ fi
523
+ done
524
+ fi
525
+ fi
526
+
527
+ # Priority 3: Extension-provided templates (always "replace")
528
+ local ext_dir="$repo_root/.specify/extensions"
529
+ if [ -d "$ext_dir" ]; then
530
+ for ext in "$ext_dir"/*/; do
531
+ [ -d "$ext" ] || continue
532
+ case "$(basename "$ext")" in .*) continue;; esac
533
+ local candidate="$ext/templates/${template_name}.md"
534
+ if [ -f "$candidate" ]; then
535
+ layer_paths+=("$candidate")
536
+ layer_strategies+=("replace")
537
+ fi
538
+ done
539
+ fi
540
+
541
+ # Priority 4: Core templates (always "replace")
542
+ local core="$base/${template_name}.md"
543
+ if [ -f "$core" ]; then
544
+ layer_paths+=("$core")
545
+ layer_strategies+=("replace")
546
+ fi
547
+
548
+ local count=${#layer_paths[@]}
549
+ [ "$count" -eq 0 ] && return 1
550
+
551
+ # Check if any layer uses a non-replace strategy
552
+ local has_composition=false
553
+ for s in "${layer_strategies[@]}"; do
554
+ [ "$s" != "replace" ] && has_composition=true && break
555
+ done
556
+
557
+ # If the top (highest-priority) layer is replace, it wins entirely —
558
+ # lower layers are irrelevant regardless of their strategies.
559
+ if [ "${layer_strategies[0]}" = "replace" ]; then
560
+ cat "${layer_paths[0]}"
561
+ return 0
562
+ fi
563
+
564
+ if [ "$has_composition" = false ]; then
565
+ cat "${layer_paths[0]}"
566
+ return 0
567
+ fi
568
+
569
+ # Find the effective base: scan from highest priority (index 0) downward
570
+ # to find the nearest replace layer. Only compose layers above that base.
571
+ local base_idx=-1
572
+ local i
573
+ for (( i=0; i<count; i++ )); do
574
+ if [ "${layer_strategies[$i]}" = "replace" ]; then
575
+ base_idx=$i
576
+ break
577
+ fi
578
+ done
579
+
580
+ if [ $base_idx -lt 0 ]; then
581
+ return 1 # no base layer found
582
+ fi
583
+
584
+ # Read the base content; compose layers above the base (higher priority)
585
+ local content
586
+ content=$(cat "${layer_paths[$base_idx]}"; printf x)
587
+ content="${content%x}"
588
+
589
+ for (( i=base_idx-1; i>=0; i-- )); do
590
+ local path="${layer_paths[$i]}"
591
+ local strat="${layer_strategies[$i]}"
592
+ local layer_content
593
+ # Preserve trailing newlines
594
+ layer_content=$(cat "$path"; printf x)
595
+ layer_content="${layer_content%x}"
596
+
597
+ case "$strat" in
598
+ replace) content="$layer_content" ;;
599
+ prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;;
600
+ append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;;
601
+ wrap)
602
+ case "$layer_content" in
603
+ *'{CORE_TEMPLATE}'*) ;;
604
+ *) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;;
605
+ esac
606
+ while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do
607
+ local before="${layer_content%%\{CORE_TEMPLATE\}*}"
608
+ local after="${layer_content#*\{CORE_TEMPLATE\}}"
609
+ layer_content="${before}${content}${after}"
610
+ done
611
+ content="$layer_content"
612
+ ;;
613
+ *) echo "Error: unknown strategy '$strat'" >&2; return 1 ;;
614
+ esac
615
+ done
616
+
617
+ printf '%s' "$content"
618
+ return 0
619
+ }