bigpowers 2.66.0 → 2.67.1

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 (48) hide show
  1. package/.fix_regex.sh +16 -0
  2. package/.hermes-verify-sed-fix.py +53 -0
  3. package/.pi/package.json +2 -2
  4. package/.pi/prompts/build-epic.md +8 -0
  5. package/.pi/prompts/gate-trace.md +2 -1
  6. package/.pi/prompts/kickoff-branch.md +19 -0
  7. package/.pi/prompts/maintain-wiki.md +63 -0
  8. package/.pi/prompts/plan-tests.md +1 -1
  9. package/.pi/prompts/release-branch.md +17 -13
  10. package/.pi/prompts/verify-work.md +9 -5
  11. package/.pi/skills/build-epic/SKILL.md +8 -0
  12. package/.pi/skills/gate-trace/SKILL.md +2 -1
  13. package/.pi/skills/kickoff-branch/SKILL.md +19 -0
  14. package/.pi/skills/maintain-wiki/SKILL.md +65 -0
  15. package/.pi/skills/plan-tests/SKILL.md +1 -1
  16. package/.pi/skills/release-branch/SKILL.md +17 -13
  17. package/.pi/skills/verify-work/SKILL.md +9 -5
  18. package/CHANGELOG.md +20 -0
  19. package/README.md +1 -1
  20. package/SKILL-INDEX.md +1 -1
  21. package/_test_e28s01.sh +2 -0
  22. package/hermes-verify-sed-fix.py +30 -0
  23. package/package.json +1 -1
  24. package/scripts/build-skill-graph.sh +151 -183
  25. package/scripts/check-spec-drift.sh +121 -0
  26. package/scripts/check-spec-version-gap.sh +143 -0
  27. package/scripts/decompose-conventions.sh +147 -0
  28. package/scripts/generate-agent-guide.sh +89 -0
  29. package/scripts/hermes-verify-e39s02.sh +28 -0
  30. package/scripts/lib/python-env.sh +44 -0
  31. package/scripts/migrate-version.sh +721 -0
  32. package/scripts/okf-post-sync.sh +163 -0
  33. package/scripts/validate-okf.sh +18 -0
  34. package/skills/build-epic/SKILL.md +8 -0
  35. package/skills/gate-trace/SKILL.md +2 -1
  36. package/skills/kickoff-branch/SKILL.md +19 -0
  37. package/skills/maintain-wiki/SKILL.md +66 -0
  38. package/skills/plan-tests/SKILL.md +1 -1
  39. package/skills/release-branch/SKILL.md +17 -13
  40. package/skills/verify-work/SKILL.md +9 -5
  41. package/skills-lock.json +11 -6
  42. package/tests/fixtures/v1.x-project/CLAUDE.md +4 -0
  43. package/tests/fixtures/v1.x-project/CONVENTIONS.md +4 -0
  44. package/tests/fixtures/v2.0.0-project/CLAUDE.md +4 -0
  45. package/tests/fixtures/v2.0.0-project/CONVENTIONS.md +3 -0
  46. package/tests/fixtures/v2.20-project/CLAUDE.md +4 -0
  47. package/tests/fixtures/v2.20-project/CONVENTIONS.md +4 -0
  48. package/tests/run-golden-migrate-version.sh +155 -0
@@ -0,0 +1,721 @@
1
+ #!/usr/bin/env bash
2
+ # story: e44s03 e44s04
3
+ # migrate-version.sh — one-shot ordered migration engine with triple safety net.
4
+ # Safety: (1) backup, (2) dry-run diff, (3) auto-commit.
5
+ set -euo pipefail
6
+
7
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
9
+ BIGPOWERS_ROOT="$REPO_ROOT"
10
+ YAML_TOOLS="$REPO_ROOT/scripts/yaml-tools.py"
11
+ SPECS_DIR="$REPO_ROOT/specs"
12
+ MIGRATIONS_DIR="$SPECS_DIR/migrations"
13
+ REGISTRY="$MIGRATIONS_DIR/registry.okf.md"
14
+ BACKUP_DIR="$SPECS_DIR/.pre-upgrade-backup"
15
+
16
+ DRY_RUN=false
17
+ FORCE=false
18
+ SKIP_COMMIT=false
19
+ VERBOSE=false
20
+
21
+ # ── usage ────────────────────────────────────────────────────────────────
22
+ usage() {
23
+ cat <<'EOF'
24
+ Usage: scripts/migrate-version.sh [flags]
25
+
26
+ Flags:
27
+ --help Show this message
28
+ --dry-run Compose migration plan and show diff, but make no changes
29
+ --force Skip active-work block detection
30
+ --no-commit Skip auto-commit (still applies transforms and stamps)
31
+ --verbose Show detailed progress for each transform step
32
+ EOF
33
+ exit 0
34
+ }
35
+
36
+ # ── helpers ───────────────────────────────────────────────────────────────
37
+ installed_version() {
38
+ node -e "console.log(require('$BIGPOWERS_ROOT/package.json').version)" 2>/dev/null || echo "unknown"
39
+ }
40
+
41
+ yaml_get() {
42
+ local file="$1" key="$2"
43
+ python3 -c "
44
+ import yaml, sys
45
+ try:
46
+ d = yaml.safe_load(open('$file'))
47
+ val = d
48
+ for k in '$key'.split('.'):
49
+ if not isinstance(val, dict): sys.exit(1)
50
+ val = val.get(k)
51
+ if val is None: sys.exit(1)
52
+ print(val)
53
+ except: sys.exit(1)
54
+ " 2>/dev/null
55
+ }
56
+
57
+ log_info() { echo " [INFO] $*"; }
58
+ log_warn() { echo " [WARN] $*"; }
59
+ log_done() { echo " [OK] $*"; }
60
+ log_fail() { echo " [FAIL] $*"; }
61
+ log_skip() { echo " [SKIP] $*"; }
62
+
63
+ # ── argument parsing ──────────────────────────────────────────────────────
64
+ PROJECT_DIR=""
65
+
66
+ for arg in "$@"; do
67
+ case "$arg" in
68
+ --help|-h) usage ;;
69
+ --dry-run) DRY_RUN=true ;;
70
+ --force) FORCE=true ;;
71
+ --no-commit) SKIP_COMMIT=true ;;
72
+ --verbose) VERBOSE=true ;;
73
+ --project)
74
+ # Next argument is the project dir
75
+ ;;
76
+ *)
77
+ if [ "${PREV_ARG:-}" = "--project" ]; then
78
+ PROJECT_DIR="$arg"
79
+ else
80
+ echo "migrate-version: unknown flag: $arg" >&2; exit 4
81
+ fi
82
+ ;;
83
+ esac
84
+ PREV_ARG="$arg"
85
+ done
86
+
87
+ # If --project given, override paths to target project; migrations remain in bigpowers
88
+ if [ -n "$PROJECT_DIR" ]; then
89
+ REPO_ROOT="$PROJECT_DIR"
90
+ SPECS_DIR="$REPO_ROOT/specs"
91
+ BACKUP_DIR="$SPECS_DIR/.pre-upgrade-backup"
92
+ fi
93
+
94
+ # ── pre-flight: active-work block ─────────────────────────────────────────
95
+ if [ "$FORCE" != "true" ]; then
96
+ branch="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null)" || true
97
+ if [ -n "${branch:-}" ] && [ "$branch" != "main" ] && [ "$branch" != "master" ]; then
98
+ echo "migrate-version: BLOCKED — on non-main branch '$branch'. Use --force to override." >&2
99
+ exit 3
100
+ fi
101
+ if ! git -C "$REPO_ROOT" diff --quiet -- specs/ 2>/dev/null || \
102
+ ! git -C "$REPO_ROOT" diff --cached --quiet -- specs/ 2>/dev/null; then
103
+ echo "migrate-version: BLOCKED — uncommitted changes in specs/. Commit or stash first." >&2
104
+ exit 3
105
+ fi
106
+ if [ -f "$SPECS_DIR/state.yaml" ]; then
107
+ af="$(yaml_get "$SPECS_DIR/state.yaml" "active_flow" 2>/dev/null)" || true
108
+ if [ -n "${af:-}" ] && [ "$af" != "null" ]; then
109
+ echo "migrate-version: BLOCKED — active_flow '$af' is set (mid-epic work). Use --force." >&2
110
+ exit 3
111
+ fi
112
+ fi
113
+ fi
114
+
115
+ # ── verify pre-conditions ─────────────────────────────────────────────────
116
+ if [ ! -d "$SPECS_DIR" ]; then
117
+ echo "migrate-version: no specs/ directory found" >&2
118
+ exit 2
119
+ fi
120
+ if [ ! -f "$REGISTRY" ]; then
121
+ echo "migrate-version: migration registry not found at $REGISTRY" >&2
122
+ exit 4
123
+ fi
124
+
125
+ INSTALLED="$(installed_version)"
126
+
127
+ # Detect version
128
+ DETECTED=""
129
+ if [ -f "$SPECS_DIR/state.yaml" ]; then
130
+ stamped="$(yaml_get "$SPECS_DIR/state.yaml" "bigpowers_version" 2>/dev/null)" || true
131
+ if [ -n "${stamped:-}" ] && [ "$stamped" != "null" ]; then
132
+ DETECTED="$stamped"
133
+ fi
134
+ fi
135
+ if [ -z "$DETECTED" ]; then
136
+ if [ -f "$SPECS_DIR/STATE.md" ] && [ ! -f "$SPECS_DIR/state.yaml" ]; then
137
+ DETECTED="1.0.0"
138
+ elif [ -f "$SPECS_DIR/state.yaml" ]; then
139
+ if yaml_get "$SPECS_DIR/state.yaml" "epic_cycle" >/dev/null 2>&1; then
140
+ DETECTED="2.20.0"
141
+ else
142
+ DETECTED="2.0.0"
143
+ fi
144
+ else
145
+ echo "migrate-version: cannot determine spec version" >&2
146
+ exit 4
147
+ fi
148
+ fi
149
+
150
+ echo "migrate-version: detected=$DETECTED installed=$INSTALLED"
151
+
152
+ if [ "$DETECTED" = "$INSTALLED" ]; then
153
+ echo "migrate-version: no version gap — specs are current"
154
+ exit 0
155
+ fi
156
+
157
+ # ── build migration plan ──────────────────────────────────────────────────
158
+ echo "migrate-version: building migration plan from $REGISTRY …"
159
+
160
+ MIGRATION_PLAN="$(python3 -c "
161
+ import yaml, json, sys
162
+
163
+ with open('$REGISTRY') as f:
164
+ parts = f.read().split('---')
165
+ fm = yaml.safe_load(parts[1])
166
+
167
+ detected = '$DETECTED'
168
+ installed = '$INSTALLED'
169
+
170
+ plan = []
171
+ for m in sorted(fm.get('migrations', []), key=lambda x: x.get('order', 99)):
172
+ sid = m['id']
173
+ since = m.get('since_version', '0')
174
+ order = m.get('order', 99)
175
+ deps = m.get('depends_on', [])
176
+ plan.append({'id': sid, 'file': m.get('file', sid + '.okf.md'),
177
+ 'since_version': since, 'order': order,
178
+ 'depends_on': deps, 'title': m.get('title', sid)})
179
+
180
+ applicable = [m for m in plan if m['since_version'] > detected and m['since_version'] <= installed]
181
+ print(json.dumps(applicable))
182
+ " 2>/dev/null)"
183
+
184
+ MIGRATION_COUNT="$(echo "$MIGRATION_PLAN" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null)"
185
+
186
+ if [ "${MIGRATION_COUNT:-0}" -eq 0 ]; then
187
+ echo "migrate-version: no applicable migrations for $DETECTED → $INSTALLED — stamping version only"
188
+ fi
189
+
190
+ echo "migrate-version: $MIGRATION_COUNT migration(s) to apply"
191
+
192
+ if [ "$VERBOSE" = "true" ]; then
193
+ echo "$MIGRATION_PLAN" | python3 -c "
194
+ import json,sys
195
+ for m in json.load(sys.stdin):
196
+ print(f\" {m['order']}. {m['id']}: {m['title']} (since {m['since_version']})\")
197
+ "
198
+ fi
199
+
200
+ # ── Safety Layer 1: backup ────────────────────────────────────────────────
201
+ if [ "$DRY_RUN" != "true" ]; then
202
+ log_info "Creating backup: $BACKUP_DIR"
203
+ rm -rf "$BACKUP_DIR"
204
+ mkdir -p "$BACKUP_DIR"
205
+ cp -r "$SPECS_DIR"/* "$BACKUP_DIR"/ 2>/dev/null || true
206
+ rm -rf "$BACKUP_DIR/.pre-upgrade-backup" 2>/dev/null || true
207
+ fi
208
+
209
+ # ── Safety Layer 2: dry-run ───────────────────────────────────────────────
210
+ if [ "$DRY_RUN" = "true" ]; then
211
+ echo ""
212
+ echo "=========================================="
213
+ echo " DRY RUN — no changes will be made"
214
+ echo "=========================================="
215
+ echo ""
216
+ echo "Detected version: $DETECTED"
217
+ echo "Target version: $INSTALLED"
218
+ echo "Migrations:"
219
+ echo "$MIGRATION_PLAN" | python3 -c "
220
+ import json,sys
221
+ for m in json.load(sys.stdin):
222
+ deps = m.get('depends_on', [])
223
+ dep_str = ' (depends on: ' + ', '.join(deps) + ')' if deps else ''
224
+ print(f\" [{m['order']}] {m['id']}: {m['title']}{dep_str}\")
225
+ "
226
+
227
+ echo ""
228
+ echo "Transforms preview:"
229
+ echo "$MIGRATION_PLAN" | python3 -c "
230
+ import json, sys, os
231
+ for m in json.load(sys.stdin):
232
+ bundle_path = os.path.join('$MIGRATIONS_DIR', m['file'])
233
+ if not os.path.exists(bundle_path):
234
+ print(f\" WARN {m['id']}: bundle file not found ({m['file']})\")
235
+ continue
236
+ with open(bundle_path) as f:
237
+ parts = f.read().split('---')
238
+ fm = __import__('yaml').safe_load(parts[1])
239
+ transforms = fm.get('transforms', [])
240
+ print(f\"\n -- {m['id']}: {m['title']} --\")
241
+ for t in transforms:
242
+ action = t.get('action', '?')
243
+ note = t.get('note', '')
244
+ note_str = f' ({note})' if note else ''
245
+ if action == 'convert_md_to_yaml':
246
+ src = t.get('source', '?')
247
+ tgt = t.get('target', '?')
248
+ unc = t.get('uncertainty', [])
249
+ print(f\" {action}: {src} -> {tgt}{note_str}\")
250
+ for u in unc:
251
+ print(f\" GUESS: {u.get('field','?')} - {u.get('reason','?')}\")
252
+ elif action in ('rename_file', 'move_file'):
253
+ print(f\" {action}: {t.get('source','?')} -> {t.get('target','?')}{note_str}\")
254
+ elif action == 'delete_file':
255
+ print(f\" {action}: {t.get('path','?')}{note_str}\")
256
+ elif action in ('set_yaml_key', 'rename_yaml_key', 'delete_yaml_key'):
257
+ print(f\" {action}: {t.get('file','?')} {t.get('key','?') or t.get('old_key','?')} = {t.get('value','?')}{note_str}\")
258
+ elif action == 'create_file_from_template':
259
+ print(f\" {action}: {t.get('source','?')} -> {t.get('target','?')}{note_str}\")
260
+ else:
261
+ print(f\" {action}{note_str}\")
262
+ for a in fm.get('actions_needed', []):
263
+ v = a.get('verify', '')
264
+ if v: print(f\" verify: {v}\")
265
+ " 2>/dev/null
266
+
267
+ echo ""
268
+ echo "Run without --dry-run to apply these migrations."
269
+ exit 0
270
+ fi
271
+
272
+ # ── Apply phase ───────────────────────────────────────────────────────────
273
+ echo ""
274
+ echo "Applying migrations …"
275
+ SUCCEEDED=()
276
+ FAILED=()
277
+ SKIPPED=()
278
+ # Default empty values for subshell-safe access
279
+ SUCCEEDED_COUNT=0
280
+ FAILED_COUNT=0
281
+ SKIPPED_COUNT=0
282
+ UNCERTAINTY_COUNT=0
283
+
284
+ apply_transform() {
285
+ local mig_id="$1" action="$2" tjson="$3"
286
+
287
+ case "$action" in
288
+ convert_md_to_yaml)
289
+ local src tgt hm unc_count arch
290
+ src="$(echo "$tjson" | python3 -c "import json,sys; print(json.load(sys.stdin).get('source',''))")"
291
+ tgt="$(echo "$tjson" | python3 -c "import json,sys; print(json.load(sys.stdin).get('target',''))")"
292
+ arch="$(echo "$tjson" | python3 -c "import json,sys; print(json.load(sys.stdin).get('archive_source','false'))" 2>/dev/null)" || arch="false"
293
+ if [ ! -f "$REPO_ROOT/$src" ]; then
294
+ log_skip "$action: source '$src' not found"
295
+ SKIPPED+=("$action:$src"); return
296
+ fi
297
+ hm="$(echo "$tjson" | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin).get('heuristic_map',{})))")"
298
+ python3 -c "
299
+ import json, re, yaml, sys, os
300
+ src = '$REPO_ROOT/$src'
301
+ tgt = '$REPO_ROOT/$tgt'
302
+ heuristic_map = json.loads('''$hm''')
303
+ content = open(src).read()
304
+ result = {}
305
+ for md_key, yaml_key in heuristic_map.items():
306
+ pat = re.compile(r'^' + re.escape(md_key.rstrip(':')) + r'\\s*[:=]\\s*(.+)$', re.MULTILINE | re.IGNORECASE)
307
+ m = pat.search(content)
308
+ if m:
309
+ val = m.group(1).strip()
310
+ try: val = yaml.safe_load(val) or val
311
+ except: pass
312
+ parts = yaml_key.split('.')
313
+ cur = result
314
+ for p in parts[:-1]:
315
+ if p not in cur or not isinstance(cur[p], dict):
316
+ cur[p] = {}
317
+ cur = cur[p]
318
+ cur[parts[-1]] = val
319
+ os.makedirs(os.path.dirname(tgt), exist_ok=True)
320
+ with open(tgt, 'w') as f:
321
+ yaml.safe_dump(result, f, sort_keys=False, allow_unicode=True, width=100)
322
+ "
323
+ unc_count="$(echo "$tjson" | python3 -c "import json,sys; print(len(json.load(sys.stdin).get('uncertainty',[])))")"
324
+ UNCERTAINTY_COUNT=$((UNCERTAINTY_COUNT + unc_count))
325
+ # Archive source file if requested
326
+ if [ "$arch" = "true" ] || [ "$arch" = "True" ]; then
327
+ local archive_dir="$REPO_ROOT/specs/archive"
328
+ mkdir -p "$archive_dir"
329
+ mv "$REPO_ROOT/$src" "$archive_dir/"
330
+ log_done "$action: $src -> $tgt (source archived to specs/archive/)"
331
+ else
332
+ log_done "$action: $src -> $tgt"
333
+ fi
334
+ ;;
335
+
336
+ rename_file)
337
+ local src tgt
338
+ src="$(echo "$tjson" | python3 -c "import json,sys; print(json.load(sys.stdin).get('source',''))")"
339
+ tgt="$(echo "$tjson" | python3 -c "import json,sys; print(json.load(sys.stdin).get('target',''))")"
340
+ if [ ! -e "$REPO_ROOT/$src" ]; then
341
+ log_skip "$action: source '$src' not found — idempotent"
342
+ SKIPPED+=("$action:$src"); return
343
+ fi
344
+ if [ -e "$REPO_ROOT/$tgt" ]; then
345
+ log_skip "$action: target '$tgt' already exists — idempotent"
346
+ SKIPPED+=("$action:$src"); return
347
+ fi
348
+ mkdir -p "$(dirname "$REPO_ROOT/$tgt")"
349
+ mv "$REPO_ROOT/$src" "$REPO_ROOT/$tgt"
350
+ log_done "$action: $src -> $tgt"
351
+ ;;
352
+
353
+ move_file)
354
+ local src tgt
355
+ src="$(echo "$tjson" | python3 -c "import json,sys; print(json.load(sys.stdin).get('source',''))")"
356
+ tgt="$(echo "$tjson" | python3 -c "import json,sys; print(json.load(sys.stdin).get('target',''))")"
357
+ if [ ! -e "$REPO_ROOT/$src" ]; then
358
+ log_skip "$action: source '$src' not found"
359
+ SKIPPED+=("$action:$src"); return
360
+ fi
361
+ if [ -e "$REPO_ROOT/$tgt" ]; then
362
+ log_skip "$action: target '$tgt' already exists — idempotent"
363
+ SKIPPED+=("$action:$src"); return
364
+ fi
365
+ mkdir -p "$(dirname "$REPO_ROOT/$tgt")"
366
+ mv "$REPO_ROOT/$src" "$REPO_ROOT/$tgt"
367
+ log_done "$action: $src -> $tgt"
368
+ ;;
369
+
370
+ delete_file)
371
+ local path
372
+ path="$(echo "$tjson" | python3 -c "import json,sys; print(json.load(sys.stdin).get('path',''))")"
373
+ if [ ! -e "$REPO_ROOT/$path" ]; then
374
+ log_skip "$action: '$path' not found — idempotent"
375
+ SKIPPED+=("$action:$path"); return
376
+ fi
377
+ rm -f "$REPO_ROOT/$path"
378
+ log_done "$action: $path"
379
+ ;;
380
+
381
+ set_yaml_key)
382
+ local file key val if_missing
383
+ file="$(echo "$tjson" | python3 -c "import json,sys; print(json.load(sys.stdin).get('file',''))")"
384
+ key="$(echo "$tjson" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('path') or d.get('key',''))")"
385
+ val="$(echo "$tjson" | python3 -c "import json,sys; print(json.load(sys.stdin).get('value',''))")"
386
+ if_missing="$(echo "$tjson" | python3 -c "import json,sys; print(json.load(sys.stdin).get('if_missing','false'))" 2>/dev/null)" || if_missing="false"
387
+ if [ "$if_missing" = "true" ] || [ "$if_missing" = "True" ]; then
388
+ if yaml_get "$REPO_ROOT/$file" "$key" >/dev/null 2>&1; then
389
+ log_skip "$action: $file $key already set (if_missing=true) — idempotent"
390
+ SKIPPED+=("$action:$key")
391
+ return
392
+ fi
393
+ fi
394
+ python3 "$YAML_TOOLS" set "$REPO_ROOT/$file" "$key" "$val"
395
+ log_done "$action: $file $key = $val"
396
+ ;;
397
+
398
+ rename_yaml_key)
399
+ local file old_key new_key old_val
400
+ file="$(echo "$tjson" | python3 -c "import json,sys; print(json.load(sys.stdin).get('file',''))")"
401
+ old_key="$(echo "$tjson" | python3 -c "import json,sys; print(json.load(sys.stdin).get('old_key',''))")"
402
+ new_key="$(echo "$tjson" | python3 -c "import json,sys; print(json.load(sys.stdin).get('new_key',''))")"
403
+ old_val="$(yaml_get "$REPO_ROOT/$file" "$old_key" 2>/dev/null)" || true
404
+ if [ -z "${old_val:-}" ]; then
405
+ log_skip "$action: key '$old_key' not found in $file"
406
+ SKIPPED+=("$action:$old_key"); return
407
+ fi
408
+ python3 "$YAML_TOOLS" set "$REPO_ROOT/$file" "$new_key" "$old_val"
409
+ python3 -c "
410
+ import yaml
411
+ data = yaml.safe_load(open('$REPO_ROOT/$file'))
412
+ parts = '$old_key'.split('.')
413
+ cur = data
414
+ for p in parts[:-1]:
415
+ if not isinstance(cur, dict): break
416
+ cur = cur.get(p, {})
417
+ if isinstance(cur, dict) and parts[-1] in cur:
418
+ del cur[parts[-1]]
419
+ with open('$REPO_ROOT/$file', 'w') as f:
420
+ yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True, width=100)
421
+ " 2>/dev/null
422
+ log_done "$action: $file $old_key -> $new_key = $old_val"
423
+ ;;
424
+
425
+ delete_yaml_key)
426
+ local file key
427
+ file="$(echo "$tjson" | python3 -c "import json,sys; print(json.load(sys.stdin).get('file',''))")"
428
+ key="$(echo "$tjson" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('path') or d.get('key',''))")"
429
+ if ! yaml_get "$REPO_ROOT/$file" "$key" >/dev/null 2>&1; then
430
+ log_skip "$action: key '$key' not found in $file — idempotent"
431
+ SKIPPED+=("$action:$key"); return
432
+ fi
433
+ python3 -c "
434
+ import yaml
435
+ data = yaml.safe_load(open('$REPO_ROOT/$file'))
436
+ parts = '$key'.split('.')
437
+ cur = data
438
+ for p in parts[:-1]:
439
+ if not isinstance(cur, dict): break
440
+ cur = cur.get(p, {})
441
+ if isinstance(cur, dict) and parts[-1] in cur:
442
+ del cur[parts[-1]]
443
+ with open('$REPO_ROOT/$file', 'w') as f:
444
+ yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True, width=100)
445
+ " 2>/dev/null
446
+ log_done "$action: $file $key"
447
+ ;;
448
+
449
+ create_file_from_template)
450
+ local src tgt subs inline_tmpl
451
+ src="$(echo "$tjson" | python3 -c "import json,sys; print(json.load(sys.stdin).get('source',''))")"
452
+ tgt="$(echo "$tjson" | python3 -c "import json,sys; print(json.load(sys.stdin).get('target',''))")"
453
+ subs="$(echo "$tjson" | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin).get('substitutions',{})))")"
454
+ inline_tmpl="$(echo "$tjson" | python3 -c "import json,sys; print(json.load(sys.stdin).get('template',''))")"
455
+ if [ -e "$REPO_ROOT/$tgt" ]; then
456
+ log_skip "$action: target '$tgt' already exists — idempotent"
457
+ SKIPPED+=("$action:$tgt"); return
458
+ fi
459
+ mkdir -p "$(dirname "$REPO_ROOT/$tgt")"
460
+ # Inline template (content embedded in OKF bundle)
461
+ if [ -n "$inline_tmpl" ]; then
462
+ echo "$inline_tmpl" > "$REPO_ROOT/$tgt"
463
+ if [ "$subs" != "{}" ]; then
464
+ python3 -c "
465
+ import json
466
+ subs = json.loads('''$subs''')
467
+ content = open('$REPO_ROOT/$tgt').read()
468
+ for k, v in subs.items():
469
+ content = content.replace(k, v)
470
+ with open('$REPO_ROOT/$tgt', 'w') as f:
471
+ f.write(content)
472
+ " 2>/dev/null
473
+ fi
474
+ log_done "$action: inline template -> $tgt"
475
+ return
476
+ fi
477
+ # File-based template resolution
478
+ template_path=""
479
+ if [ -n "$src" ]; then
480
+ # Try source as relative path first, then as template name
481
+ if [ -f "$REPO_ROOT/$src" ]; then
482
+ template_path="$REPO_ROOT/$src"
483
+ elif [ -f "$REPO_ROOT/specs/templates/${src##*/}" ]; then
484
+ template_path="$REPO_ROOT/specs/templates/${src##*/}"
485
+ fi
486
+ fi
487
+ if [ -z "$template_path" ] || [ ! -f "$template_path" ]; then
488
+ log_skip "$action: template not found (src='$src')"
489
+ SKIPPED+=("$action:$src"); return
490
+ fi
491
+ cp "$template_path" "$REPO_ROOT/$tgt"
492
+ python3 -c "
493
+ import json
494
+ subs = json.loads('''$subs''')
495
+ content = open('$REPO_ROOT/$tgt').read()
496
+ for k, v in subs.items():
497
+ content = content.replace(k, v)
498
+ with open('$REPO_ROOT/$tgt', 'w') as f:
499
+ f.write(content)
500
+ " 2>/dev/null
501
+ log_done "$action: $src -> $tgt"
502
+ ;;
503
+
504
+ *)
505
+ log_skip "$action: unknown transform type"
506
+ SKIPPED+=("$action")
507
+ ;;
508
+ esac
509
+ }
510
+
511
+ # Execute each migration in order
512
+ MIG_LIST="$(echo "$MIGRATION_PLAN" | python3 -c "import json,sys; [print(json.dumps(m)) for m in json.load(sys.stdin)]")"
513
+ while IFS= read -r mig_json; do
514
+ [ -z "$mig_json" ] && continue
515
+ MIG_ID="$(echo "$mig_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")"
516
+ MIG_FILE="$(echo "$mig_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['file'])")"
517
+ MIG_TITLE="$(echo "$mig_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['title'])")"
518
+
519
+ BUNDLE_PATH="$MIGRATIONS_DIR/$MIG_FILE"
520
+ if [ ! -f "$BUNDLE_PATH" ]; then
521
+ log_warn "$MIG_ID: bundle file not found — skipping"
522
+ SKIPPED+=("$MIG_ID:bundle_missing")
523
+ continue
524
+ fi
525
+
526
+ echo ""
527
+ echo "── $MIG_ID: $MIG_TITLE ──"
528
+
529
+ TRANSFORMS="$(python3 -c "
530
+ import yaml, json
531
+ with open('$BUNDLE_PATH') as f:
532
+ parts = f.read().split('---')
533
+ fm = yaml.safe_load(parts[1])
534
+ print(json.dumps(fm.get('transforms', []), default=str))
535
+ " 2>/dev/null)"
536
+
537
+ T_LIST="$(echo "$TRANSFORMS" | python3 -c "import json,sys; [print(json.dumps(t, default=str)) for t in json.load(sys.stdin)]")"
538
+ while IFS= read -r tjson; do
539
+ [ -z "$tjson" ] && continue
540
+ action="$(echo "$tjson" | python3 -c "import json,sys; print(json.load(sys.stdin).get('action',''))")"
541
+ apply_transform "$MIG_ID" "$action" "$tjson"
542
+ done <<< "$T_LIST"
543
+
544
+ # Run verify commands
545
+ VERIFY_CMDS="$(python3 -c "
546
+ import yaml, json
547
+ with open('$BUNDLE_PATH') as f:
548
+ parts = f.read().split('---')
549
+ fm = yaml.safe_load(parts[1])
550
+ for a in fm.get('actions_needed', []):
551
+ v = a.get('verify', '')
552
+ if v: print(v)
553
+ " 2>/dev/null)"
554
+
555
+ VERIFY_OK=true
556
+ while IFS= read -r vcmd; do
557
+ [ -z "$vcmd" ] && continue
558
+ log_info "verify: $vcmd"
559
+ if eval "$vcmd" 2>&1; then
560
+ log_done "verify passed"
561
+ else
562
+ log_fail "verify failed"
563
+ VERIFY_OK=false
564
+ fi
565
+ done <<< "$VERIFY_CMDS"
566
+
567
+ if [ "$VERIFY_OK" = "true" ]; then
568
+ SUCCEEDED+=("$MIG_ID")
569
+ else
570
+ FAILED+=("$MIG_ID")
571
+ fi
572
+ done <<< "$MIG_LIST"
573
+
574
+ # ── stamp ─────────────────────────────────────────────────────────────────
575
+ if [ -f "$SPECS_DIR/state.yaml" ]; then
576
+ log_info "Stamping bigpowers_version=$INSTALLED in state.yaml"
577
+ python3 "$YAML_TOOLS" set "$SPECS_DIR/state.yaml" "bigpowers_version" "$INSTALLED"
578
+ log_done "stamp applied"
579
+ fi
580
+
581
+ # ── validate-okf soft gate ────────────────────────────────────────────────
582
+ if [ -f "$SCRIPT_DIR/validate-okf.sh" ]; then
583
+ log_info "Running validate-okf.sh (soft gate) …"
584
+ if bash "$SCRIPT_DIR/validate-okf.sh" --dir "$MIGRATIONS_DIR" 2>&1; then
585
+ log_done "validate-okf: PASS"
586
+ else
587
+ log_warn "validate-okf: some checks failed (soft gate — non-blocking)"
588
+ fi
589
+ else
590
+ log_info "validate-okf.sh not found — skipping soft gate"
591
+ fi
592
+
593
+ # ── global validate-specs-yaml gate ───────────────────────────────────────
594
+ if [ -f "$SCRIPT_DIR/validate-specs-yaml.sh" ]; then
595
+ log_info "Running global validate-specs-yaml.sh …"
596
+ if bash "$SCRIPT_DIR/validate-specs-yaml.sh" 2>&1; then
597
+ log_done "validate-specs-yaml: PASS"
598
+ else
599
+ log_fail "validate-specs-yaml: FAIL"
600
+ FAILED+=("validate-specs-yaml")
601
+ fi
602
+ fi
603
+
604
+ # ── CLAUDE.md / CONVENTIONS.md staleness check ────────────────────────────
605
+ STALE_FILES=()
606
+ BIGPOWERS_CLAUDE="$SCRIPT_DIR/../CLAUDE.md"
607
+ if [ -f "$REPO_ROOT/CLAUDE.md" ] && [ -f "$BIGPOWERS_CLAUDE" ]; then
608
+ proj_lines="$(wc -l < "$REPO_ROOT/CLAUDE.md")"
609
+ bp_lines="$(wc -l < "$BIGPOWERS_CLAUDE")"
610
+ delta=$((bp_lines - proj_lines))
611
+ if [ $delta -gt 20 ]; then
612
+ STALE_FILES+=("CLAUDE.md")
613
+ log_warn "CLAUDE.md: $delta lines behind bigpowers template — consider updating"
614
+ fi
615
+ fi
616
+ if [ -f "$REPO_ROOT/CONVENTIONS.md" ] && [ -f "$SCRIPT_DIR/../CONVENTIONS.md" ]; then
617
+ proj_lines="$(wc -l < "$REPO_ROOT/CONVENTIONS.md")"
618
+ bp_lines="$(wc -l < "$SCRIPT_DIR/../CONVENTIONS.md")"
619
+ delta=$((bp_lines - proj_lines))
620
+ if [ $delta -gt 20 ]; then
621
+ STALE_FILES+=("CONVENTIONS.md")
622
+ log_warn "CONVENTIONS.md: $delta lines behind bigpowers template — consider updating"
623
+ fi
624
+ fi
625
+
626
+ # ── generate migration report ─────────────────────────────────────────────
627
+ ISO_DATE="$(date -u +%Y-%m-%dT%H%M%SZ)"
628
+ REPORT_FILE="$SPECS_DIR/migration-report-$ISO_DATE.md"
629
+
630
+ cat > "$REPORT_FILE" <<REPORT
631
+ # Migration Report — $ISO_DATE
632
+
633
+ ## Gap Summary
634
+
635
+ | Field | Value |
636
+ |-------|-------|
637
+ | Detected version | $DETECTED |
638
+ | Installed version | $INSTALLED |
639
+ | Migration count | ${MIGRATION_COUNT:-0} |
640
+
641
+ ## Migrations Applied
642
+
643
+ REPORT
644
+
645
+ for m in "${SUCCEEDED[@]:-}"; do
646
+ [ -z "$m" ] && continue
647
+ echo "- $m PASS" >> "$REPORT_FILE"
648
+ done
649
+ FAILED_COUNT="${#FAILED[@]:-0}"
650
+ if [ "${FAILED_COUNT}" -gt 0 ]; then
651
+ echo "" >> "$REPORT_FILE"
652
+ echo "## Migrations Failed" >> "$REPORT_FILE"
653
+ for m in "${FAILED[@]}"; do
654
+ echo "- $m FAIL" >> "$REPORT_FILE"
655
+ done
656
+ fi
657
+ if [ ${#SKIPPED[@]} -gt 0 ]; then
658
+ echo "" >> "$REPORT_FILE"
659
+ echo "## Skipped" >> "$REPORT_FILE"
660
+ for m in "${SKIPPED[@]}"; do
661
+ echo "- $m" >> "$REPORT_FILE"
662
+ done
663
+ fi
664
+ echo "" >> "$REPORT_FILE"
665
+ echo "## Uncertainties" >> "$REPORT_FILE"
666
+ echo "WARN count: $UNCERTAINTY_COUNT — review specs/ for GUESS markers and adjust if needed." >> "$REPORT_FILE"
667
+ echo "" >> "$REPORT_FILE"
668
+ echo "## Verification" >> "$REPORT_FILE"
669
+ echo "- validate-specs-yaml: ran as final gate" >> "$REPORT_FILE"
670
+
671
+ if [ ${#STALE_FILES[@]:-0} -gt 0 ]; then
672
+ echo "" >> "$REPORT_FILE"
673
+ echo "## Staleness Notice" >> "$REPORT_FILE"
674
+ for f in "${STALE_FILES[@]:-}"; do
675
+ [ -z "$f" ] && continue
676
+ echo "- $f is behind the current bigpowers template" >> "$REPORT_FILE"
677
+ done
678
+ fi
679
+
680
+ echo "" >> "$REPORT_FILE"
681
+ echo "## Next Steps" >> "$REPORT_FILE"
682
+ echo "1. Review the migration report and verify spec correctness" >> "$REPORT_FILE"
683
+ echo "2. Run \`bash scripts/sync-skills.sh\` to regenerate agent artifacts" >> "$REPORT_FILE"
684
+ echo "3. Commit changes if not auto-committed" >> "$REPORT_FILE"
685
+ echo "" >> "$REPORT_FILE"
686
+ echo "## Rollback" >> "$REPORT_FILE"
687
+ echo "To rollback, restore from backup:" >> "$REPORT_FILE"
688
+ echo "\`\`\`bash" >> "$REPORT_FILE"
689
+ echo "rm -rf specs/ && cp -r $BACKUP_DIR/ specs/" >> "$REPORT_FILE"
690
+ echo "\`\`\`" >> "$REPORT_FILE"
691
+
692
+ log_done "Migration report: $REPORT_FILE"
693
+
694
+ # ── terminal summary ──────────────────────────────────────────────────────
695
+ echo ""
696
+ echo "=========================================="
697
+ echo " Migration complete: $DETECTED -> $INSTALLED"
698
+ echo "=========================================="
699
+ echo " Applied: ${#SUCCEEDED[@]:-0}"
700
+ echo " Failed: ${#FAILED[@]:-0}"
701
+ echo " Skipped: ${#SKIPPED[@]:-0}"
702
+ echo " WARN: $UNCERTAINTY_COUNT"
703
+ [ ${#STALE_FILES[@]:-0} -gt 0 ] && echo " Stale: ${STALE_FILES[*]:-}"
704
+ echo " Report: $REPORT_FILE"
705
+ echo "=========================================="
706
+ echo " Remember: run 'bash scripts/sync-skills.sh'"
707
+ echo "=========================================="
708
+
709
+ # ── Safety Layer 3: auto-commit ───────────────────────────────────────────
710
+ if [ "$SKIP_COMMIT" != "true" ] && [ "${MIGRATION_COUNT:-0}" -gt 0 ]; then
711
+ log_info "Auto-committing migration …"
712
+ git -C "$REPO_ROOT" add specs/ 2>/dev/null || true
713
+ COMMIT_MSG="migrate-version: $DETECTED -> $INSTALLED (${MIGRATION_COUNT:-0} migration(s))"
714
+ if git -C "$REPO_ROOT" commit -m "$COMMIT_MSG" 2>/dev/null; then
715
+ log_done "committed: $COMMIT_MSG"
716
+ else
717
+ log_info "nothing to commit (already clean)"
718
+ fi
719
+ fi
720
+
721
+ exit 0