@pennyfarthing/core 7.7.0 → 7.8.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 (56) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/packages/core/dist/cli/commands/doctor.d.ts.map +1 -1
  4. package/packages/core/dist/cli/commands/doctor.js +114 -0
  5. package/packages/core/dist/cli/commands/doctor.js.map +1 -1
  6. package/pennyfarthing-dist/agents/sm-setup.md +37 -2
  7. package/pennyfarthing-dist/agents/sm.md +68 -22
  8. package/pennyfarthing-dist/agents/workflow-status-check.md +11 -1
  9. package/pennyfarthing-dist/commands/git-cleanup.md +43 -308
  10. package/pennyfarthing-dist/commands/solo.md +31 -0
  11. package/pennyfarthing-dist/guides/patterns/approval-gates-pattern.md +1 -1
  12. package/pennyfarthing-dist/personas/themes/gilligans-island.yaml +83 -83
  13. package/pennyfarthing-dist/personas/themes/the-expanse.yaml +11 -11
  14. package/pennyfarthing-dist/scripts/core/check-context.sh +3 -0
  15. package/pennyfarthing-dist/scripts/core/handoff-marker.sh +13 -2
  16. package/pennyfarthing-dist/scripts/core/prime.sh +3 -157
  17. package/pennyfarthing-dist/scripts/core/run.sh +9 -0
  18. package/pennyfarthing-dist/scripts/hooks/__pycache__/question_reflector_check.cpython-314.pyc +0 -0
  19. package/pennyfarthing-dist/scripts/hooks/question_reflector_check.py +117 -20
  20. package/pennyfarthing-dist/scripts/jira/README.md +10 -7
  21. package/pennyfarthing-dist/scripts/misc/add-short-names.sh +13 -0
  22. package/pennyfarthing-dist/scripts/misc/add_short_names.py +226 -0
  23. package/pennyfarthing-dist/scripts/misc/migrate-bmad-workflow.sh +6 -5
  24. package/pennyfarthing-dist/scripts/misc/migrate_bmad_workflow.py +319 -0
  25. package/pennyfarthing-dist/scripts/sprint/import-epic-to-future.sh +6 -5
  26. package/pennyfarthing-dist/scripts/sprint/import_epic_to_future.py +270 -0
  27. package/pennyfarthing-dist/scripts/test/ensure-swebench-data.sh +59 -0
  28. package/pennyfarthing-dist/scripts/theme/compute-theme-tiers.sh +8 -6
  29. package/pennyfarthing-dist/scripts/theme/compute_theme_tiers.py +402 -0
  30. package/pennyfarthing-dist/scripts/workflow/check.sh +3 -476
  31. package/pennyfarthing-dist/scripts/workflow/get-workflow-type.py +61 -0
  32. package/pennyfarthing-dist/scripts/workflow/get-workflow-type.sh +13 -0
  33. package/pennyfarthing-dist/skills/judge/SKILL.md +57 -0
  34. package/pennyfarthing-dist/skills/sprint/scripts/sync-epic-jira.sh +4 -22
  35. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-01-analyze.md +83 -0
  36. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-02-categorize.md +116 -0
  37. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-03-execute.md +210 -0
  38. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-04-verify.md +88 -0
  39. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-05-complete.md +71 -0
  40. package/pennyfarthing-dist/workflows/git-cleanup.yaml +59 -0
  41. package/pennyfarthing-dist/scripts/hooks/question-reflector-check.mjs +0 -393
  42. package/pennyfarthing-dist/scripts/hooks/tests/question-reflector.test.mjs +0 -545
  43. package/pennyfarthing-dist/scripts/jira/jira-bidirectional-sync.mjs +0 -327
  44. package/pennyfarthing-dist/scripts/jira/jira-bidirectional-sync.test.mjs +0 -503
  45. package/pennyfarthing-dist/scripts/jira/jira-lib.mjs +0 -443
  46. package/pennyfarthing-dist/scripts/jira/jira-sync-story.mjs +0 -208
  47. package/pennyfarthing-dist/scripts/jira/jira-sync.mjs +0 -198
  48. package/pennyfarthing-dist/scripts/misc/add-short-names.mjs +0 -264
  49. package/pennyfarthing-dist/scripts/misc/migrate-bmad-workflow.mjs +0 -474
  50. package/pennyfarthing-dist/scripts/sprint/import-epic-to-future.mjs +0 -377
  51. package/pennyfarthing-dist/scripts/theme/compute-theme-tiers.js +0 -492
  52. /package/pennyfarthing-dist/guides/{AGENT-COORDINATION.md → agent-coordination.md} +0 -0
  53. /package/pennyfarthing-dist/guides/{HOOKS.md → hooks.md} +0 -0
  54. /package/pennyfarthing-dist/guides/{PROMPT-PATTERNS.md → prompt-patterns.md} +0 -0
  55. /package/pennyfarthing-dist/guides/{SESSION-ARTIFACTS.md → session-artifacts.md} +0 -0
  56. /package/pennyfarthing-dist/guides/{XML-TAGS.md → xml-tags.md} +0 -0
@@ -69,6 +69,19 @@ CHOICE_PATTERNS = [
69
69
  re.compile(r'\bchoose between\b', re.IGNORECASE),
70
70
  ]
71
71
 
72
+ # Handoff phrase patterns - SM saying they'll hand off but not actually doing it
73
+ HANDOFF_PHRASE_PATTERNS = [
74
+ re.compile(r'\bhanding (off )?to\b', re.IGNORECASE),
75
+ re.compile(r'\bpassing to\b', re.IGNORECASE),
76
+ re.compile(r'\bhand(ing)? this (off )?to\b', re.IGNORECASE),
77
+ re.compile(r'\b(Naomi|Amos|Avasarala|Holden|Alex|Drummer) (will|can)\b', re.IGNORECASE),
78
+ re.compile(r'\bfor (the )?(GREEN|RED|REVIEW) phase\b', re.IGNORECASE),
79
+ re.compile(r'\bspawning .* agent\b', re.IGNORECASE),
80
+ ]
81
+
82
+ # Task tool invocation pattern in transcript
83
+ TASK_TOOL_PATTERN = re.compile(r'"tool_name":\s*"Task"', re.IGNORECASE)
84
+
72
85
 
73
86
  # =============================================================================
74
87
  # Helper Functions
@@ -147,6 +160,55 @@ def detect_question(message: str) -> dict[str, Any]:
147
160
  return {'detected': False, 'type': ''}
148
161
 
149
162
 
163
+ def detect_handoff_phrase(message: str) -> bool:
164
+ """Detect if a message contains handoff language (promising to hand off).
165
+
166
+ Args:
167
+ message: The message to check
168
+
169
+ Returns:
170
+ True if handoff language detected
171
+ """
172
+ # Strip code blocks first
173
+ clean_message = strip_code_blocks(message)
174
+
175
+ for pattern in HANDOFF_PHRASE_PATTERNS:
176
+ if pattern.search(clean_message):
177
+ return True
178
+ return False
179
+
180
+
181
+ def has_task_tool_in_turn(transcript: list[dict[str, Any]]) -> bool:
182
+ """Check if the current turn includes a Task tool invocation.
183
+
184
+ Args:
185
+ transcript: Array of message objects
186
+
187
+ Returns:
188
+ True if Task tool was used in the current turn
189
+ """
190
+ # Find messages in the current turn (after the last user message)
191
+ current_turn_start = -1
192
+ for i, entry in enumerate(transcript):
193
+ msg = entry.get('message', entry)
194
+ if msg.get('role') == 'user':
195
+ current_turn_start = i
196
+
197
+ if current_turn_start < 0:
198
+ return False
199
+
200
+ # Check all entries after last user message for Task tool
201
+ for entry in transcript[current_turn_start:]:
202
+ msg = entry.get('message', entry)
203
+ if msg.get('role') == 'assistant':
204
+ content = msg.get('content', [])
205
+ if isinstance(content, list):
206
+ for block in content:
207
+ if block.get('type') == 'tool_use' and block.get('name') == 'Task':
208
+ return True
209
+ return False
210
+
211
+
150
212
  def has_reflector_marker(message: str) -> bool:
151
213
  """Check if a message has ANY valid CYCLIST reflector marker.
152
214
 
@@ -195,33 +257,53 @@ def extract_last_assistant_message(transcript: list[dict[str, Any]]) -> str:
195
257
  return ''
196
258
 
197
259
 
198
- def build_block_reason(question_type: str) -> str:
260
+ def build_block_reason(question_type: str, handoff_without_task: bool = False) -> str:
199
261
  """Build the block reason message.
200
262
 
263
+ Provides actionable guidance so Claude can emit JUST the marker
264
+ on retry rather than regenerating the entire response.
265
+
201
266
  Args:
202
267
  question_type: The type of question detected (or empty for general)
268
+ handoff_without_task: True if handoff language detected but no Task tool used
203
269
 
204
270
  Returns:
205
- The reason message
271
+ The reason message with suggested marker
206
272
  """
207
- reason = 'Every turn MUST end with a CYCLIST reflector marker. '
273
+ # Special case: handoff language without Task tool
274
+ if handoff_without_task:
275
+ return (
276
+ 'HANDOFF COMPLIANCE VIOLATION: You said you would hand off but did NOT use the Task tool.\n\n'
277
+ 'SM Protocol: Task tool FIRST, narration SECOND.\n\n'
278
+ 'Either:\n'
279
+ '1. Actually spawn the agent now using the Task tool, OR\n'
280
+ '2. If you completed the work yourself, remove handoff language and add <!-- CYCLIST:CONTINUE -->'
281
+ )
282
+
283
+ # Key insight: Tell Claude to ONLY emit the marker, not regenerate everything
284
+ reason = 'Missing CYCLIST marker. Your response content is fine - just APPEND the marker.\n\n'
208
285
 
209
286
  if question_type:
210
- # Specific question type detected
287
+ # Specific question type detected - suggest exact marker
211
288
  if question_type == 'direct':
212
- reason += 'You asked a question. Add <!-- CYCLIST:QUESTION:yesno --> or <!-- CYCLIST:QUESTION:open --> before your question.'
289
+ reason += 'Detected: direct question (?)\n'
290
+ reason += 'APPEND THIS: <!-- CYCLIST:QUESTION:open -->\n'
291
+ reason += '(Use yesno if it\'s a yes/no question)'
213
292
  elif question_type == 'implicit':
214
- reason += 'You asked an implicit question. Add <!-- CYCLIST:QUESTION:yesno --> before phrases like "would you like" or "should I".'
293
+ reason += 'Detected: implicit question (would you like, should I, etc.)\n'
294
+ reason += 'APPEND THIS: <!-- CYCLIST:QUESTION:yesno -->'
215
295
  elif question_type == 'choices':
216
- reason += 'You offered choices. Add <!-- CYCLIST:CHOICES:option1,option2,option3 --> listing the choices.'
296
+ reason += 'Detected: choice offering\n'
297
+ reason += 'APPEND THIS: <!-- CYCLIST:CHOICES:option1,option2 -->\n'
298
+ reason += '(Replace option1,option2 with actual choices)'
217
299
  else:
218
- # No question detected, but still need a marker
219
- reason += 'Valid markers:\n'
300
+ # No question detected - suggest CONTINUE marker
301
+ reason += 'No question detected - this looks like a status update.\n'
302
+ reason += 'APPEND THIS: <!-- CYCLIST:CONTINUE -->\n\n'
303
+ reason += 'Other markers if needed:\n'
220
304
  reason += ' <!-- CYCLIST:HANDOFF:/agent --> - workflow handoff\n'
221
305
  reason += ' <!-- CYCLIST:QUESTION:yesno --> - yes/no question\n'
222
- reason += ' <!-- CYCLIST:QUESTION:open --> - open question\n'
223
- reason += ' <!-- CYCLIST:CHOICES:a,b,c --> - multiple choice\n'
224
- reason += ' <!-- CYCLIST:CONTINUE --> - status update, user may continue or redirect'
306
+ reason += ' <!-- CYCLIST:QUESTION:open --> - open question'
225
307
 
226
308
  return reason
227
309
 
@@ -229,14 +311,19 @@ def build_block_reason(question_type: str) -> str:
229
311
  def check_question_reflector(
230
312
  input_data: dict[str, Any],
231
313
  config: dict[str, Any],
232
- last_message: str
314
+ last_message: str,
315
+ transcript: list[dict[str, Any]] | None = None
233
316
  ) -> dict[str, Any]:
234
317
  """Main check for Stop hook - validates ALL turns have reflector markers.
235
318
 
319
+ Also enforces handoff compliance: if handoff language detected without
320
+ Task tool usage, blocks the turn.
321
+
236
322
  Args:
237
323
  input_data: Hook input with transcript_path, stop_hook_active
238
324
  config: Config with workflow settings
239
325
  last_message: The last assistant message (pre-extracted for testing)
326
+ transcript: Full transcript for checking Task tool usage
240
327
 
241
328
  Returns:
242
329
  { 'ok': True } or { 'decision': 'block', 'reason': str }
@@ -257,6 +344,16 @@ def check_question_reflector(
257
344
  if has_reflector_marker(last_message):
258
345
  return {'ok': True}
259
346
 
347
+ # HANDOFF COMPLIANCE CHECK:
348
+ # If handoff language detected but no Task tool was used, block
349
+ if detect_handoff_phrase(last_message):
350
+ has_task = transcript and has_task_tool_in_turn(transcript)
351
+ if not has_task:
352
+ return {
353
+ 'decision': 'block',
354
+ 'reason': build_block_reason('', handoff_without_task=True),
355
+ }
356
+
260
357
  # No marker found - block
261
358
  # Check if it's a question to give more specific guidance
262
359
  detection = detect_question(last_message)
@@ -331,21 +428,21 @@ def load_config(project_dir: str) -> dict[str, Any]:
331
428
  return {'workflow': {'permission_mode': 'manual'}}
332
429
 
333
430
 
334
- def read_transcript(transcript_path: str) -> str:
431
+ def read_transcript(transcript_path: str) -> tuple[str, list[dict[str, Any]]]:
335
432
  """Read transcript and extract last assistant message.
336
433
 
337
434
  Args:
338
435
  transcript_path: Path to JSONL transcript
339
436
 
340
437
  Returns:
341
- The last assistant message
438
+ Tuple of (last assistant message, full transcript)
342
439
  """
343
440
  try:
344
441
  content = Path(transcript_path).read_text()
345
442
  lines = [line for line in content.strip().split('\n') if line]
346
443
 
347
444
  # Parse JSONL and build transcript array
348
- transcript = []
445
+ transcript: list[dict[str, Any]] = []
349
446
  for line in lines:
350
447
  try:
351
448
  transcript.append(json.loads(line))
@@ -353,9 +450,9 @@ def read_transcript(transcript_path: str) -> str:
353
450
  # Skip malformed lines
354
451
  pass
355
452
 
356
- return extract_last_assistant_message(transcript)
453
+ return extract_last_assistant_message(transcript), transcript
357
454
  except Exception:
358
- return ''
455
+ return '', []
359
456
 
360
457
 
361
458
  def main() -> None:
@@ -385,8 +482,8 @@ def main() -> None:
385
482
  else:
386
483
  # Stop hook
387
484
  transcript_path = input_data.get('transcript_path', '')
388
- last_message = read_transcript(transcript_path) if transcript_path else ''
389
- result = check_question_reflector(input_data, config, last_message)
485
+ last_message, transcript = read_transcript(transcript_path) if transcript_path else ('', [])
486
+ result = check_question_reflector(input_data, config, last_message, transcript)
390
487
  print(json.dumps(result))
391
488
 
392
489
  sys.exit(0)
@@ -7,18 +7,21 @@ Scripts for Jira integration, synchronization, and story management.
7
7
  | Script | Purpose |
8
8
  |--------|---------|
9
9
  | `jira-lib.sh` | Shared Jira bash utilities (library) |
10
- | `jira-lib.mjs` | Shared Jira JavaScript utilities (library) |
11
10
  | `jira-claim-story.sh` | Claim a story (assign and move to In Progress) |
12
11
  | `jira-reconcile.sh` | Reconcile Jira with sprint YAML |
13
- | `jira-sync.sh` | Sync story to Jira |
14
- | `jira-sync.mjs` | Sync story to Jira (JavaScript) |
15
- | `jira-sync-story.sh` | Sync individual story |
16
- | `jira-sync-story.mjs` | Sync individual story (JavaScript) |
12
+ | `jira-sync.sh` | Sync story to Jira (wrapper → Python) |
13
+ | `jira-sync-story.sh` | Sync individual story (wrapper Python) |
17
14
  | `create-jira-epic.sh` | Create Jira epic with stories |
18
15
  | `create-jira-story.sh` | Create individual Jira story |
19
- | `sync-epic-jira.sh` | Sync epic to Jira |
16
+ | `sync-epic-jira.sh` | Sync epic to Jira (wrapper → Python) |
20
17
  | `sync-epic-to-jira.sh` | Sync epic to Jira (alternate) |
21
- | `jira-bidirectional-sync.mjs` | Bidirectional sync between YAML and Jira |
18
+
19
+ ## Python Implementation
20
+
21
+ Core logic lives in `pennyfarthing_scripts/jira/`:
22
+ - `sync.py` — Epic and story sync
23
+ - `bidirectional.py` — Two-way YAML ↔ Jira sync
24
+ - `story.py` — Story operations
22
25
 
23
26
  ## Usage
24
27
 
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bash
2
+ # add-short-names.sh - Pre-generate shortName field for theme characters
3
+ #
4
+ # Usage:
5
+ # add-short-names.sh # Dry run - show what would change
6
+ # add-short-names.sh --write # Actually write changes
7
+ # add-short-names.sh --theme discworld # Only process one theme
8
+
9
+ set -euo pipefail
10
+
11
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12
+
13
+ exec python3 "$SCRIPT_DIR/add_short_names.py" "$@"
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ add_short_names.py - Pre-generate shortName field for theme characters
4
+
5
+ Finds the shortest unique identifier that distinguishes each character.
6
+
7
+ Usage:
8
+ python add_short_names.py # Dry run - show what would change
9
+ python add_short_names.py --write # Actually write changes
10
+ python add_short_names.py --theme discworld # Only process one theme
11
+ """
12
+
13
+ import argparse
14
+ import re
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ try:
19
+ import yaml
20
+ except ImportError:
21
+ print("Error: PyYAML required. Install with: pip install pyyaml", file=sys.stderr)
22
+ sys.exit(1)
23
+
24
+
25
+ def find_project_root() -> Path:
26
+ """Find project root by looking for .claude directory."""
27
+ current = Path.cwd()
28
+ while current != current.parent:
29
+ if (current / ".claude").is_dir():
30
+ return current
31
+ current = current.parent
32
+ return Path.cwd()
33
+
34
+
35
+ # Common titles/prefixes to strip for comparison
36
+ SKIP_PREFIXES = {
37
+ 'the', 'dr.', 'dr', 'captain', 'admiral', 'colonel', 'lieutenant', 'commander',
38
+ 'president', 'lord', 'lady', 'sir', 'professor', 'inspector', 'sergeant',
39
+ 'mr.', 'mr', 'mrs.', 'mrs', 'miss', 'ms.', 'ms', 'chief', 'major', 'general',
40
+ 'king', 'queen', 'prince', 'princess', 'duke', 'earl', 'count', 'baron',
41
+ 'first', 'grand', 'arch', 'high',
42
+ 'uncle', 'aunt', 'brother', 'sister', 'father', 'mother', 'friar',
43
+ 'avatar', 'agent', 'detective', 'officer', 'private', 'corporal',
44
+ 'chancellor', 'ambassador', 'senator', 'governor', 'minister',
45
+ 'master', 'young', 'old', 'elder', 'reverend', 'bishop', 'cardinal'
46
+ }
47
+
48
+ # Words that make poor short names on their own
49
+ POOR_SHORT_NAMES = {
50
+ 'big', 'little', 'old', 'young', 'true', 'false', 'good', 'bad',
51
+ 'thought', 'ministry', 'situation', 'room', 'place', 'house',
52
+ 'superintendent', 'commander', 'speaker', 'council',
53
+ 'mode', 'narrator', 'chronicler',
54
+ 'h.m.', 'j.f.', 'a.w.', 'e.b.', 'l.'
55
+ }
56
+
57
+ # Names that should use the full form (iconic two-word names)
58
+ USE_FULL_NAME = {'big brother', 'sun tzu'}
59
+
60
+
61
+ def extract_nickname(name: str) -> str | None:
62
+ """Extract quoted nickname from character name if present."""
63
+ match = re.search(r'["\']([^"\']+)["\']', name)
64
+ if match:
65
+ nickname = match.group(1).strip()
66
+ if nickname and ' ' not in nickname:
67
+ return nickname
68
+ return None
69
+
70
+
71
+ def clean_name(name: str) -> str:
72
+ """Clean character name by removing parenthetical annotations and slash alternatives."""
73
+ cleaned = re.sub(r'\s*\([^)]+\)\s*', ' ', name).strip()
74
+ cleaned = re.sub(r'\b\w+/(\w+)\s', r'\1 ', cleaned)
75
+ cleaned = re.sub(r'\s*["\'][^"\']+["\']\s*', ' ', cleaned).strip()
76
+ cleaned = re.sub(r'\s+', ' ', cleaned).strip()
77
+ return cleaned
78
+
79
+
80
+ def tokenize(name: str) -> list[str]:
81
+ """Tokenize a name into meaningful parts."""
82
+ cleaned = clean_name(name)
83
+ words = [w for w in cleaned.split() if w]
84
+
85
+ filtered = [
86
+ w for w in words
87
+ if w.lower() not in SKIP_PREFIXES
88
+ and not re.match(r'^[A-Z]\.$', w)
89
+ and not re.match(r'^[IVXLCDM]+$', w)
90
+ ]
91
+
92
+ return filtered if filtered else words
93
+
94
+
95
+ def compute_short_names(agents: dict) -> dict[str, str]:
96
+ """Compute display name map for all characters in a theme."""
97
+ characters = [
98
+ a['character'] for a in agents.values()
99
+ if a and a.get('character')
100
+ ]
101
+
102
+ def is_unique(candidate: str, except_for: str) -> bool:
103
+ candidate_lower = candidate.lower()
104
+ for char in characters:
105
+ if char == except_for:
106
+ continue
107
+ tokens = tokenize(char)
108
+ if any(t.lower() == candidate_lower for t in tokens):
109
+ return False
110
+ return True
111
+
112
+ def is_good_short_name(candidate: str) -> bool:
113
+ return candidate.lower() not in POOR_SHORT_NAMES and len(candidate) > 1
114
+
115
+ def find_short_name(full_name: str) -> str:
116
+ cleaned = clean_name(full_name)
117
+
118
+ if cleaned.lower() in USE_FULL_NAME:
119
+ return cleaned
120
+
121
+ nickname = extract_nickname(full_name)
122
+ if nickname and is_good_short_name(nickname):
123
+ return nickname
124
+
125
+ tokens = tokenize(full_name)
126
+
127
+ if not tokens:
128
+ return cleaned
129
+
130
+ if len(tokens) == 1:
131
+ return tokens[0]
132
+
133
+ # Strategy 1: First token (if good and unique)
134
+ if is_good_short_name(tokens[0]) and is_unique(tokens[0], full_name):
135
+ return tokens[0]
136
+
137
+ # Strategy 2: Last token (surname, if good and unique)
138
+ last_token = tokens[-1]
139
+ if is_good_short_name(last_token) and is_unique(last_token, full_name):
140
+ return last_token
141
+
142
+ # Strategy 3: First + Last
143
+ if len(tokens) >= 2:
144
+ first_last = f"{tokens[0]} {last_token}"
145
+ if is_unique(first_last, full_name):
146
+ return first_last
147
+
148
+ return clean_name(full_name)
149
+
150
+ return {char: find_short_name(char) for char in characters}
151
+
152
+
153
+ def process_theme(filepath: Path, dry_run: bool = True) -> dict:
154
+ """Process a single theme file."""
155
+ content = filepath.read_text()
156
+ theme = yaml.safe_load(content)
157
+
158
+ if not theme or 'agents' not in theme:
159
+ print(f" Skipping {filepath.name} - no agents found")
160
+ return {'changes': 0, 'filename': filepath.name}
161
+
162
+ short_names = compute_short_names(theme['agents'])
163
+ changes = 0
164
+
165
+ for role, agent in theme['agents'].items():
166
+ if not agent or 'character' not in agent:
167
+ continue
168
+
169
+ short_name = short_names.get(agent['character'])
170
+ existing = agent.get('shortName')
171
+
172
+ if short_name and short_name != existing:
173
+ if dry_run:
174
+ existing_note = f' (was: "{existing}")' if existing else ''
175
+ print(f' {role}: "{agent["character"]}" -> "{short_name}"{existing_note}')
176
+ agent['shortName'] = short_name
177
+ changes += 1
178
+
179
+ if not dry_run and changes > 0:
180
+ with open(filepath, 'w') as f:
181
+ yaml.dump(theme, f, default_flow_style=False, allow_unicode=True, width=1000)
182
+ print(f" Wrote {changes} changes to {filepath.name}")
183
+
184
+ return {'changes': changes, 'filename': filepath.name}
185
+
186
+
187
+ def main() -> int:
188
+ parser = argparse.ArgumentParser(
189
+ description="Pre-generate shortName field for theme characters"
190
+ )
191
+ parser.add_argument('--write', action='store_true',
192
+ help='Actually write changes (default: dry run)')
193
+ parser.add_argument('--theme', type=str,
194
+ help='Only process one theme')
195
+ args = parser.parse_args()
196
+
197
+ dry_run = not args.write
198
+
199
+ project_root = find_project_root()
200
+ themes_dir = project_root / 'pennyfarthing-dist' / 'personas' / 'themes'
201
+
202
+ print('DRY RUN - No files will be modified\n' if dry_run else 'WRITING CHANGES\n')
203
+
204
+ files = sorted(themes_dir.glob('*.yaml'))
205
+ if args.theme:
206
+ theme_file = f"{args.theme}.yaml" if not args.theme.endswith('.yaml') else args.theme
207
+ files = [f for f in files if f.name == theme_file]
208
+
209
+ total_changes = 0
210
+ for file in files:
211
+ print(f"\n{file.name}:")
212
+ result = process_theme(file, dry_run)
213
+ total_changes += result['changes']
214
+ if result['changes'] == 0:
215
+ print(' (no changes needed)')
216
+
217
+ print(f"\n{'=' * 50}")
218
+ print(f"Total: {total_changes} changes across {len(files)} themes")
219
+ if dry_run and total_changes > 0:
220
+ print("\nRun with --write to apply changes")
221
+
222
+ return 0
223
+
224
+
225
+ if __name__ == "__main__":
226
+ sys.exit(main())
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env bash
2
- # migrate-bmad-workflow.sh - Thin wrapper for migrate-bmad-workflow.mjs
2
+ # migrate-bmad-workflow.sh - Migrate BMAD workflows to Pennyfarthing format
3
3
  #
4
4
  # Usage: ./scripts/migrate-bmad-workflow.sh [--dry-run] <source-dir> [target-dir]
5
- #
6
- # Delegates to Node script for cleaner YAML parsing and variable conversion.
7
5
 
8
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
9
- exec node "${SCRIPT_DIR}/migrate-bmad-workflow.mjs" "$@"
6
+ set -euo pipefail
7
+
8
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+
10
+ exec python3 "$SCRIPT_DIR/migrate_bmad_workflow.py" "$@"