@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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/packages/core/dist/cli/commands/doctor.d.ts.map +1 -1
- package/packages/core/dist/cli/commands/doctor.js +114 -0
- package/packages/core/dist/cli/commands/doctor.js.map +1 -1
- package/pennyfarthing-dist/agents/sm-setup.md +37 -2
- package/pennyfarthing-dist/agents/sm.md +68 -22
- package/pennyfarthing-dist/agents/workflow-status-check.md +11 -1
- package/pennyfarthing-dist/commands/git-cleanup.md +43 -308
- package/pennyfarthing-dist/commands/solo.md +31 -0
- package/pennyfarthing-dist/guides/patterns/approval-gates-pattern.md +1 -1
- package/pennyfarthing-dist/personas/themes/gilligans-island.yaml +83 -83
- package/pennyfarthing-dist/personas/themes/the-expanse.yaml +11 -11
- package/pennyfarthing-dist/scripts/core/check-context.sh +3 -0
- package/pennyfarthing-dist/scripts/core/handoff-marker.sh +13 -2
- package/pennyfarthing-dist/scripts/core/prime.sh +3 -157
- package/pennyfarthing-dist/scripts/core/run.sh +9 -0
- package/pennyfarthing-dist/scripts/hooks/__pycache__/question_reflector_check.cpython-314.pyc +0 -0
- package/pennyfarthing-dist/scripts/hooks/question_reflector_check.py +117 -20
- package/pennyfarthing-dist/scripts/jira/README.md +10 -7
- package/pennyfarthing-dist/scripts/misc/add-short-names.sh +13 -0
- package/pennyfarthing-dist/scripts/misc/add_short_names.py +226 -0
- package/pennyfarthing-dist/scripts/misc/migrate-bmad-workflow.sh +6 -5
- package/pennyfarthing-dist/scripts/misc/migrate_bmad_workflow.py +319 -0
- package/pennyfarthing-dist/scripts/sprint/import-epic-to-future.sh +6 -5
- package/pennyfarthing-dist/scripts/sprint/import_epic_to_future.py +270 -0
- package/pennyfarthing-dist/scripts/test/ensure-swebench-data.sh +59 -0
- package/pennyfarthing-dist/scripts/theme/compute-theme-tiers.sh +8 -6
- package/pennyfarthing-dist/scripts/theme/compute_theme_tiers.py +402 -0
- package/pennyfarthing-dist/scripts/workflow/check.sh +3 -476
- package/pennyfarthing-dist/scripts/workflow/get-workflow-type.py +61 -0
- package/pennyfarthing-dist/scripts/workflow/get-workflow-type.sh +13 -0
- package/pennyfarthing-dist/skills/judge/SKILL.md +57 -0
- package/pennyfarthing-dist/skills/sprint/scripts/sync-epic-jira.sh +4 -22
- package/pennyfarthing-dist/workflows/git-cleanup/steps/step-01-analyze.md +83 -0
- package/pennyfarthing-dist/workflows/git-cleanup/steps/step-02-categorize.md +116 -0
- package/pennyfarthing-dist/workflows/git-cleanup/steps/step-03-execute.md +210 -0
- package/pennyfarthing-dist/workflows/git-cleanup/steps/step-04-verify.md +88 -0
- package/pennyfarthing-dist/workflows/git-cleanup/steps/step-05-complete.md +71 -0
- package/pennyfarthing-dist/workflows/git-cleanup.yaml +59 -0
- package/pennyfarthing-dist/scripts/hooks/question-reflector-check.mjs +0 -393
- package/pennyfarthing-dist/scripts/hooks/tests/question-reflector.test.mjs +0 -545
- package/pennyfarthing-dist/scripts/jira/jira-bidirectional-sync.mjs +0 -327
- package/pennyfarthing-dist/scripts/jira/jira-bidirectional-sync.test.mjs +0 -503
- package/pennyfarthing-dist/scripts/jira/jira-lib.mjs +0 -443
- package/pennyfarthing-dist/scripts/jira/jira-sync-story.mjs +0 -208
- package/pennyfarthing-dist/scripts/jira/jira-sync.mjs +0 -198
- package/pennyfarthing-dist/scripts/misc/add-short-names.mjs +0 -264
- package/pennyfarthing-dist/scripts/misc/migrate-bmad-workflow.mjs +0 -474
- package/pennyfarthing-dist/scripts/sprint/import-epic-to-future.mjs +0 -377
- package/pennyfarthing-dist/scripts/theme/compute-theme-tiers.js +0 -492
- /package/pennyfarthing-dist/guides/{AGENT-COORDINATION.md → agent-coordination.md} +0 -0
- /package/pennyfarthing-dist/guides/{HOOKS.md → hooks.md} +0 -0
- /package/pennyfarthing-dist/guides/{PROMPT-PATTERNS.md → prompt-patterns.md} +0 -0
- /package/pennyfarthing-dist/guides/{SESSION-ARTIFACTS.md → session-artifacts.md} +0 -0
- /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
|
-
|
|
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 += '
|
|
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 += '
|
|
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 += '
|
|
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
|
|
219
|
-
reason += '
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 -
|
|
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
|
-
|
|
9
|
-
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
9
|
+
|
|
10
|
+
exec python3 "$SCRIPT_DIR/migrate_bmad_workflow.py" "$@"
|