@pennyfarthing/core 7.6.0 → 7.7.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. package/README.md +109 -201
  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 +91 -0
  5. package/packages/core/dist/cli/commands/doctor.js.map +1 -1
  6. package/packages/core/dist/cli/commands/init.js +31 -0
  7. package/packages/core/dist/cli/commands/init.js.map +1 -1
  8. package/packages/core/dist/cli/commands/update.js +31 -0
  9. package/packages/core/dist/cli/commands/update.js.map +1 -1
  10. package/pennyfarthing-dist/agents/architect.md +48 -53
  11. package/pennyfarthing-dist/agents/dev.md +74 -164
  12. package/pennyfarthing-dist/agents/devops.md +44 -39
  13. package/pennyfarthing-dist/agents/handoff.md +46 -23
  14. package/pennyfarthing-dist/agents/orchestrator.md +84 -255
  15. package/pennyfarthing-dist/agents/pm.md +40 -50
  16. package/pennyfarthing-dist/agents/reviewer-preflight.md +58 -26
  17. package/pennyfarthing-dist/agents/reviewer.md +107 -298
  18. package/pennyfarthing-dist/agents/sm-file-summary.md +51 -30
  19. package/pennyfarthing-dist/agents/sm-finish.md +59 -38
  20. package/pennyfarthing-dist/agents/sm-handoff.md +40 -33
  21. package/pennyfarthing-dist/agents/sm-setup.md +89 -47
  22. package/pennyfarthing-dist/agents/sm.md +171 -558
  23. package/pennyfarthing-dist/agents/tea.md +77 -146
  24. package/pennyfarthing-dist/agents/tech-writer.md +43 -24
  25. package/pennyfarthing-dist/agents/testing-runner.md +73 -30
  26. package/pennyfarthing-dist/agents/ux-designer.md +39 -25
  27. package/pennyfarthing-dist/agents/workflow-status-check.md +34 -16
  28. package/pennyfarthing-dist/commands/benchmark.md +19 -1
  29. package/pennyfarthing-dist/commands/continue-session.md +1 -1
  30. package/pennyfarthing-dist/commands/solo.md +5 -0
  31. package/pennyfarthing-dist/commands/theme-maker.md +5 -5
  32. package/pennyfarthing-dist/commands/work.md +1 -1
  33. package/pennyfarthing-dist/guides/XML-TAGS.md +179 -0
  34. package/pennyfarthing-dist/guides/agent-behavior.md +37 -2
  35. package/pennyfarthing-dist/guides/agent-tag-taxonomy.md +432 -0
  36. package/pennyfarthing-dist/guides/patterns/approval-gates-pattern.md +27 -7
  37. package/pennyfarthing-dist/guides/scale-levels.md +114 -0
  38. package/pennyfarthing-dist/personas/themes/gilligans-island.yaml +2 -2
  39. package/pennyfarthing-dist/personas/themes/star-trek-tos.yaml +1 -1
  40. package/pennyfarthing-dist/scripts/core/agent-session.sh +13 -7
  41. package/pennyfarthing-dist/scripts/core/check-context.sh +25 -8
  42. package/pennyfarthing-dist/scripts/core/prime.sh +57 -32
  43. package/pennyfarthing-dist/scripts/git/create-feature-branches.sh +45 -4
  44. package/pennyfarthing-dist/scripts/git/git-status-all.sh +32 -7
  45. package/pennyfarthing-dist/scripts/hooks/bell-mode-hook.sh +30 -11
  46. package/pennyfarthing-dist/scripts/hooks/pre-commit.sh +80 -23
  47. package/pennyfarthing-dist/scripts/hooks/question-reflector-check.mjs +393 -0
  48. package/pennyfarthing-dist/scripts/hooks/question-reflector-check.sh +20 -0
  49. package/pennyfarthing-dist/scripts/hooks/question_reflector_check.py +402 -0
  50. package/pennyfarthing-dist/scripts/hooks/session-stop.sh +7 -0
  51. package/pennyfarthing-dist/scripts/hooks/tests/question-reflector.test.mjs +545 -0
  52. package/pennyfarthing-dist/scripts/hooks/welcome-hook.sh +94 -0
  53. package/pennyfarthing-dist/scripts/jira/jira-claim-story.sh +10 -152
  54. package/pennyfarthing-dist/scripts/jira/jira-sync-story.sh +14 -4
  55. package/pennyfarthing-dist/scripts/jira/jira-sync.sh +12 -4
  56. package/pennyfarthing-dist/scripts/jira/sync-epic-jira.sh +11 -99
  57. package/pennyfarthing-dist/scripts/lib/common.sh +55 -0
  58. package/pennyfarthing-dist/scripts/maintenance/sidecar-health.sh +97 -0
  59. package/pennyfarthing-dist/scripts/misc/deploy.sh +13 -1
  60. package/pennyfarthing-dist/scripts/misc/statusline.sh +27 -22
  61. package/pennyfarthing-dist/scripts/story/create-story.sh +14 -154
  62. package/pennyfarthing-dist/scripts/story/size-story.sh +12 -192
  63. package/pennyfarthing-dist/scripts/story/story-template.sh +12 -156
  64. package/pennyfarthing-dist/scripts/test/ground-truth-judge.py +24 -93
  65. package/pennyfarthing-dist/scripts/test/swebench-judge.py +33 -59
  66. package/pennyfarthing-dist/scripts/validation/validate-agent-schema.sh +575 -0
  67. package/pennyfarthing-dist/scripts/workflow/check.py +502 -0
  68. package/pennyfarthing-dist/skills/skill-registry.yaml +52 -16
  69. package/pennyfarthing-dist/skills/sprint/skill.md +1 -1
  70. package/pennyfarthing-dist/templates/settings.local.json.template +11 -0
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ reflector_check.py - CYCLIST reflector marker enforcement hook
4
+
5
+ Story: MSSCI-12393 (questions), extended for all markers
6
+
7
+ EVERY turn end MUST have a CYCLIST reflector marker. This ensures:
8
+ - Cyclist UI can render appropriate buttons/actions
9
+ - User always has opportunity to intervene
10
+ - Workflow handoffs are never silently dropped
11
+
12
+ Valid markers (any one required):
13
+ <!-- CYCLIST:HANDOFF:/agent --> - Workflow handoff to next agent
14
+ <!-- CYCLIST:CONTEXT_CLEAR:/agent --> - Handoff with context clear (TirePump)
15
+ <!-- CYCLIST:QUESTION:yesno --> - Yes/no question
16
+ <!-- CYCLIST:QUESTION:open --> - Open-ended question
17
+ <!-- CYCLIST:CHOICES:opt1,opt2,opt3 --> - Multiple choice
18
+ <!-- CYCLIST:CONTINUE --> - Status update, user can continue or redirect
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import re
26
+ import sys
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ # =============================================================================
31
+ # Constants
32
+ # =============================================================================
33
+
34
+ # Marker patterns - ALL valid CYCLIST markers
35
+ QUESTION_MARKER_PATTERN = re.compile(r'<!--\s*CYCLIST:QUESTION:(yesno|open)\s*-->', re.IGNORECASE)
36
+ CHOICES_MARKER_PATTERN = re.compile(r'<!--\s*CYCLIST:CHOICES:[^>]+\s*-->', re.IGNORECASE)
37
+ HANDOFF_MARKER_PATTERN = re.compile(r'<!--\s*CYCLIST:HANDOFF:/\w+\s*-->', re.IGNORECASE)
38
+ CONTEXT_CLEAR_MARKER_PATTERN = re.compile(r'<!--\s*CYCLIST:CONTEXT_CLEAR:/\w+\s*-->', re.IGNORECASE)
39
+ CONTINUE_MARKER_PATTERN = re.compile(r'<!--\s*CYCLIST:CONTINUE\s*-->', re.IGNORECASE)
40
+
41
+ # Question patterns - direct (with ?)
42
+ # Match: end of line, followed by space+capital (new sentence), or followed by newline
43
+ DIRECT_QUESTION_PATTERN = re.compile(r'\?(\s*$|\s+[A-Z]|\s*\n)')
44
+
45
+ # Rhetorical patterns to exclude
46
+ RHETORICAL_PATTERNS = re.compile(r'\b(the question (was|is)|asked whether|wondering if)\b', re.IGNORECASE)
47
+
48
+ # Implicit question patterns
49
+ IMPLICIT_PATTERNS = [
50
+ re.compile(r'\bwould you like\b', re.IGNORECASE),
51
+ re.compile(r'\bshould I\b', re.IGNORECASE),
52
+ re.compile(r'\bdo you want\b', re.IGNORECASE),
53
+ re.compile(r'\blet me know if\b', re.IGNORECASE),
54
+ re.compile(r'\bwhat do you (think|prefer)\b', re.IGNORECASE),
55
+ re.compile(r'\byour (preference|thoughts)\b', re.IGNORECASE),
56
+ re.compile(r'\bcould you (clarify|confirm|specify)\b', re.IGNORECASE),
57
+ re.compile(r'\bwhich (option|approach)\b', re.IGNORECASE),
58
+ re.compile(r'\bready to proceed\b', re.IGNORECASE),
59
+ ]
60
+
61
+ # Choice offering patterns
62
+ CHOICE_PATTERNS = [
63
+ re.compile(r'\boption [A-D]\b', re.IGNORECASE),
64
+ re.compile(r'\bchoice [0-9]\b', re.IGNORECASE),
65
+ re.compile(r'\bwe could (either|do)\b', re.IGNORECASE),
66
+ re.compile(r'\balternatively\b', re.IGNORECASE),
67
+ re.compile(r'\bor would you prefer\b', re.IGNORECASE),
68
+ re.compile(r'\bpick one\b', re.IGNORECASE),
69
+ re.compile(r'\bchoose between\b', re.IGNORECASE),
70
+ ]
71
+
72
+
73
+ # =============================================================================
74
+ # Helper Functions
75
+ # =============================================================================
76
+
77
+ def strip_code_blocks(text: str) -> str:
78
+ """Strip fenced code blocks from text to avoid false positives.
79
+
80
+ Args:
81
+ text: The text to process
82
+
83
+ Returns:
84
+ Text with code blocks removed
85
+ """
86
+ # Remove fenced code blocks (```...```)
87
+ result = re.sub(r'```[\s\S]*?```', '', text)
88
+ # Remove inline code (`...`)
89
+ result = re.sub(r'`[^`]+`', '', result)
90
+ return result
91
+
92
+
93
+ # =============================================================================
94
+ # Exported Functions
95
+ # =============================================================================
96
+
97
+ def should_skip_enforcement(config: dict[str, Any]) -> bool:
98
+ """Check if enforcement should be skipped based on config.
99
+
100
+ Args:
101
+ config: The config object with workflow settings
102
+
103
+ Returns:
104
+ True if enforcement should be skipped
105
+ """
106
+ # Skip enforcement in CLI mode - markers are only needed for Cyclist UI
107
+ # Cyclist sets CYCLIST=1 in the environment when spawning Claude
108
+ if os.environ.get('CYCLIST') != '1':
109
+ return True
110
+
111
+ # In Cyclist mode, never skip enforcement - markers must always be emitted.
112
+ # relay_mode only controls whether Cyclist auto-executes markers
113
+ # vs showing buttons to the user.
114
+ return False
115
+
116
+
117
+ def detect_question(message: str) -> dict[str, Any]:
118
+ """Detect if a message contains a question.
119
+
120
+ Args:
121
+ message: The message to check
122
+
123
+ Returns:
124
+ Detection result with 'detected' bool and 'type' string
125
+ """
126
+ # Strip code blocks first
127
+ clean_message = strip_code_blocks(message)
128
+
129
+ # Check for rhetorical patterns - if found, not a real question
130
+ if RHETORICAL_PATTERNS.search(clean_message):
131
+ return {'detected': False, 'type': ''}
132
+
133
+ # Check for direct questions (with ?)
134
+ if DIRECT_QUESTION_PATTERN.search(clean_message):
135
+ return {'detected': True, 'type': 'direct'}
136
+
137
+ # Check for implicit questions
138
+ for pattern in IMPLICIT_PATTERNS:
139
+ if pattern.search(clean_message):
140
+ return {'detected': True, 'type': 'implicit'}
141
+
142
+ # Check for choice offerings
143
+ for pattern in CHOICE_PATTERNS:
144
+ if pattern.search(clean_message):
145
+ return {'detected': True, 'type': 'choices'}
146
+
147
+ return {'detected': False, 'type': ''}
148
+
149
+
150
+ def has_reflector_marker(message: str) -> bool:
151
+ """Check if a message has ANY valid CYCLIST reflector marker.
152
+
153
+ Args:
154
+ message: The message to check
155
+
156
+ Returns:
157
+ True if any marker is present
158
+ """
159
+ return bool(
160
+ QUESTION_MARKER_PATTERN.search(message) or
161
+ CHOICES_MARKER_PATTERN.search(message) or
162
+ HANDOFF_MARKER_PATTERN.search(message) or
163
+ CONTEXT_CLEAR_MARKER_PATTERN.search(message) or
164
+ CONTINUE_MARKER_PATTERN.search(message)
165
+ )
166
+
167
+
168
+ def extract_last_assistant_message(transcript: list[dict[str, Any]]) -> str:
169
+ """Extract the last assistant message from a transcript.
170
+
171
+ Args:
172
+ transcript: Array of message objects
173
+
174
+ Returns:
175
+ The last assistant message content
176
+ """
177
+ # Find the last assistant message (reverse order)
178
+ # Claude Code transcript format wraps messages: { message: { role, content }, type, ... }
179
+ for entry in reversed(transcript):
180
+ # Support both wrapped format (Claude Code JSONL) and direct format (tests)
181
+ msg = entry.get('message', entry)
182
+ if msg.get('role') == 'assistant':
183
+ content = msg.get('content', '')
184
+ # Handle content as string or array
185
+ if isinstance(content, str):
186
+ return content
187
+ if isinstance(content, list):
188
+ # Extract text from text blocks, skip tool_use blocks
189
+ return ''.join(
190
+ block.get('text', '')
191
+ for block in content
192
+ if block.get('type') == 'text'
193
+ )
194
+ return ''
195
+ return ''
196
+
197
+
198
+ def build_block_reason(question_type: str) -> str:
199
+ """Build the block reason message.
200
+
201
+ Args:
202
+ question_type: The type of question detected (or empty for general)
203
+
204
+ Returns:
205
+ The reason message
206
+ """
207
+ reason = 'Every turn MUST end with a CYCLIST reflector marker. '
208
+
209
+ if question_type:
210
+ # Specific question type detected
211
+ if question_type == 'direct':
212
+ reason += 'You asked a question. Add <!-- CYCLIST:QUESTION:yesno --> or <!-- CYCLIST:QUESTION:open --> before your question.'
213
+ elif question_type == 'implicit':
214
+ reason += 'You asked an implicit question. Add <!-- CYCLIST:QUESTION:yesno --> before phrases like "would you like" or "should I".'
215
+ elif question_type == 'choices':
216
+ reason += 'You offered choices. Add <!-- CYCLIST:CHOICES:option1,option2,option3 --> listing the choices.'
217
+ else:
218
+ # No question detected, but still need a marker
219
+ reason += 'Valid markers:\n'
220
+ reason += ' <!-- CYCLIST:HANDOFF:/agent --> - workflow handoff\n'
221
+ 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'
225
+
226
+ return reason
227
+
228
+
229
+ def check_question_reflector(
230
+ input_data: dict[str, Any],
231
+ config: dict[str, Any],
232
+ last_message: str
233
+ ) -> dict[str, Any]:
234
+ """Main check for Stop hook - validates ALL turns have reflector markers.
235
+
236
+ Args:
237
+ input_data: Hook input with transcript_path, stop_hook_active
238
+ config: Config with workflow settings
239
+ last_message: The last assistant message (pre-extracted for testing)
240
+
241
+ Returns:
242
+ { 'ok': True } or { 'decision': 'block', 'reason': str }
243
+ """
244
+ # Prevent infinite loops
245
+ if input_data.get('stop_hook_active'):
246
+ return {'ok': True}
247
+
248
+ # Skip enforcement in relay/turbo mode
249
+ if should_skip_enforcement(config):
250
+ return {'ok': True}
251
+
252
+ # If no message, allow (edge case - shouldn't happen)
253
+ if not last_message:
254
+ return {'ok': True}
255
+
256
+ # If ANY marker present, allow
257
+ if has_reflector_marker(last_message):
258
+ return {'ok': True}
259
+
260
+ # No marker found - block
261
+ # Check if it's a question to give more specific guidance
262
+ detection = detect_question(last_message)
263
+ return {
264
+ 'decision': 'block',
265
+ 'reason': build_block_reason(detection['type'] if detection['detected'] else ''),
266
+ }
267
+
268
+
269
+ def check_ask_user_question(
270
+ input_data: dict[str, Any],
271
+ config: dict[str, Any],
272
+ recent_output: str = ''
273
+ ) -> dict[str, Any]:
274
+ """Check for AskUserQuestion PreToolUse hook.
275
+
276
+ Args:
277
+ input_data: Hook input with tool_name, tool_input
278
+ config: Config with workflow settings
279
+ recent_output: Recent assistant output to check for marker
280
+
281
+ Returns:
282
+ { 'ok': True } or { 'decision': 'block', 'reason': str }
283
+ """
284
+ # Skip enforcement in relay/turbo mode
285
+ if should_skip_enforcement(config):
286
+ return {'ok': True}
287
+
288
+ # If marker present in recent output, allow
289
+ if recent_output and has_reflector_marker(recent_output):
290
+ return {'ok': True}
291
+
292
+ # Block - AskUserQuestion requires a marker
293
+ return {
294
+ 'decision': 'block',
295
+ 'reason': 'AskUserQuestion tool requires a CYCLIST marker. Add <!-- CYCLIST:QUESTION:yesno -->, <!-- CYCLIST:QUESTION:open -->, or <!-- CYCLIST:CHOICES:... --> before using this tool.',
296
+ }
297
+
298
+
299
+ # =============================================================================
300
+ # CLI Entry Point (for bash wrapper)
301
+ # =============================================================================
302
+
303
+ def load_config(project_dir: str) -> dict[str, Any]:
304
+ """Load config from .pennyfarthing/config.local.yaml.
305
+
306
+ Args:
307
+ project_dir: The project directory
308
+
309
+ Returns:
310
+ The config object
311
+ """
312
+ try:
313
+ config_path = Path(project_dir) / '.pennyfarthing' / 'config.local.yaml'
314
+ content = config_path.read_text()
315
+ # Simple YAML parsing for the fields we need
316
+ config: dict[str, Any] = {'workflow': {}}
317
+
318
+ # Extract permission_mode
319
+ mode_match = re.search(r'permission_mode:\s*(\w+)', content)
320
+ if mode_match:
321
+ config['workflow']['permission_mode'] = mode_match.group(1)
322
+
323
+ # Extract relay_mode
324
+ relay_match = re.search(r'relay_mode:\s*(true|false)', content)
325
+ if relay_match:
326
+ config['workflow']['relay_mode'] = relay_match.group(1) == 'true'
327
+
328
+ return config
329
+ except Exception:
330
+ # Default config if file doesn't exist
331
+ return {'workflow': {'permission_mode': 'manual'}}
332
+
333
+
334
+ def read_transcript(transcript_path: str) -> str:
335
+ """Read transcript and extract last assistant message.
336
+
337
+ Args:
338
+ transcript_path: Path to JSONL transcript
339
+
340
+ Returns:
341
+ The last assistant message
342
+ """
343
+ try:
344
+ content = Path(transcript_path).read_text()
345
+ lines = [line for line in content.strip().split('\n') if line]
346
+
347
+ # Parse JSONL and build transcript array
348
+ transcript = []
349
+ for line in lines:
350
+ try:
351
+ transcript.append(json.loads(line))
352
+ except json.JSONDecodeError:
353
+ # Skip malformed lines
354
+ pass
355
+
356
+ return extract_last_assistant_message(transcript)
357
+ except Exception:
358
+ return ''
359
+
360
+
361
+ def main() -> None:
362
+ """Main CLI entry point."""
363
+ # Read input from stdin
364
+ input_data_str = sys.stdin.read()
365
+
366
+ try:
367
+ input_data = json.loads(input_data_str)
368
+ except json.JSONDecodeError:
369
+ # Invalid input - allow to prevent breaking
370
+ print(json.dumps({'ok': True}))
371
+ sys.exit(0)
372
+
373
+ # Determine project directory
374
+ project_dir = os.environ.get('CLAUDE_PROJECT_DIR', os.getcwd())
375
+
376
+ # Load config
377
+ config = load_config(project_dir)
378
+
379
+ # Determine hook type based on input
380
+ if input_data.get('tool_name') == 'AskUserQuestion':
381
+ # PreToolUse hook for AskUserQuestion
382
+ # For PreToolUse, we'd need the recent output - for now, just check config
383
+ result = check_ask_user_question(input_data, config, '')
384
+ print(json.dumps(result))
385
+ else:
386
+ # Stop hook
387
+ 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)
390
+ print(json.dumps(result))
391
+
392
+ sys.exit(0)
393
+
394
+
395
+ if __name__ == '__main__':
396
+ try:
397
+ main()
398
+ except Exception as err:
399
+ print(str(err), file=sys.stderr)
400
+ # On error, allow to prevent breaking
401
+ print(json.dumps({'ok': True}))
402
+ sys.exit(0)
@@ -15,6 +15,13 @@ set -euo pipefail
15
15
 
16
16
  # Load shared functions
17
17
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
18
+
19
+ # Determine project root (directory containing .claude)
20
+ PROJECT_ROOT="$SCRIPT_DIR"
21
+ while [[ ! -d "$PROJECT_ROOT/.claude" ]] && [[ "$PROJECT_ROOT" != "/" ]]; do
22
+ PROJECT_ROOT="$(dirname "$PROJECT_ROOT")"
23
+ done
24
+
18
25
  source "$SCRIPT_DIR/../lib/checkpoint.sh"
19
26
 
20
27
  # Read input from stdin (contains session_id, source, etc.)