@pennyfarthing/core 7.6.1 → 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 (68) 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 +22 -9
  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 +6 -1
  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 +66 -53
  48. package/pennyfarthing-dist/scripts/hooks/question-reflector-check.sh +4 -4
  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/welcome-hook.sh +94 -0
  52. package/pennyfarthing-dist/scripts/jira/jira-claim-story.sh +10 -152
  53. package/pennyfarthing-dist/scripts/jira/jira-sync-story.sh +14 -4
  54. package/pennyfarthing-dist/scripts/jira/jira-sync.sh +12 -4
  55. package/pennyfarthing-dist/scripts/jira/sync-epic-jira.sh +11 -99
  56. package/pennyfarthing-dist/scripts/lib/common.sh +55 -0
  57. package/pennyfarthing-dist/scripts/maintenance/sidecar-health.sh +97 -0
  58. package/pennyfarthing-dist/scripts/misc/statusline.sh +27 -22
  59. package/pennyfarthing-dist/scripts/story/create-story.sh +14 -154
  60. package/pennyfarthing-dist/scripts/story/size-story.sh +12 -192
  61. package/pennyfarthing-dist/scripts/story/story-template.sh +12 -156
  62. package/pennyfarthing-dist/scripts/test/ground-truth-judge.py +24 -93
  63. package/pennyfarthing-dist/scripts/test/swebench-judge.py +33 -59
  64. package/pennyfarthing-dist/scripts/validation/validate-agent-schema.sh +575 -0
  65. package/pennyfarthing-dist/scripts/workflow/check.py +502 -0
  66. package/pennyfarthing-dist/skills/skill-registry.yaml +52 -16
  67. package/pennyfarthing-dist/skills/sprint/skill.md +1 -1
  68. package/pennyfarthing-dist/templates/settings.local.json.template +11 -0
@@ -1,21 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * question-reflector-check.mjs - Question reflector enforcement hook
3
+ * reflector-check.mjs - CYCLIST reflector marker enforcement hook
4
4
  *
5
- * Story: MSSCI-12393
5
+ * Story: MSSCI-12393 (questions), extended for all markers
6
6
  *
7
- * Validates that any question asked by the agent has an appropriate
8
- * CYCLIST reflector marker. Used by both Stop hook and PreToolUse hook.
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
9
11
  *
10
- * Question types detected:
11
- * - Direct questions (ends with ?)
12
- * - Implicit questions (would you like, should I, let me know if)
13
- * - Choice offerings (option A or B, we could do X or Y)
14
- *
15
- * Required markers:
16
- * <!-- CYCLIST:QUESTION:yesno -->
17
- * <!-- CYCLIST:QUESTION:open -->
18
- * <!-- CYCLIST:CHOICES:opt1,opt2,opt3 -->
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
19
  */
20
20
 
21
21
  import { readFileSync } from 'fs';
@@ -25,9 +25,12 @@ import { join, dirname } from 'path';
25
25
  // Constants
26
26
  // =============================================================================
27
27
 
28
- // Marker patterns
28
+ // Marker patterns - ALL valid CYCLIST markers
29
29
  const QUESTION_MARKER_PATTERN = /<!--\s*CYCLIST:QUESTION:(yesno|open)\s*-->/i;
30
30
  const CHOICES_MARKER_PATTERN = /<!--\s*CYCLIST:CHOICES:[^>]+\s*-->/i;
31
+ const HANDOFF_MARKER_PATTERN = /<!--\s*CYCLIST:HANDOFF:\/\w+\s*-->/i;
32
+ const CONTEXT_CLEAR_MARKER_PATTERN = /<!--\s*CYCLIST:CONTEXT_CLEAR:\/\w+\s*-->/i;
33
+ const CONTINUE_MARKER_PATTERN = /<!--\s*CYCLIST:CONTINUE\s*-->/i;
31
34
 
32
35
  // Question patterns - direct (with ?)
33
36
  // Match: end of line, followed by space+capital (new sentence), or followed by newline
@@ -87,18 +90,15 @@ function stripCodeBlocks(text) {
87
90
  * @returns {boolean} True if enforcement should be skipped
88
91
  */
89
92
  export function shouldSkipEnforcement(config) {
90
- const workflow = config?.workflow || {};
91
-
92
- // Legacy: turbo mode skips enforcement
93
- if (workflow.permission_mode === 'turbo') {
94
- return true;
95
- }
96
-
97
- // New: relay_mode skips enforcement (for auto-handoff flows)
98
- if (workflow.relay_mode === true) {
93
+ // Skip enforcement in CLI mode - markers are only needed for Cyclist UI
94
+ // Cyclist sets CYCLIST=1 in the environment when spawning Claude
95
+ if (process.env.CYCLIST !== '1') {
99
96
  return true;
100
97
  }
101
98
 
99
+ // In Cyclist mode, never skip enforcement - markers must always be emitted.
100
+ // relay_mode only controls whether Cyclist auto-executes markers
101
+ // vs showing buttons to the user.
102
102
  return false;
103
103
  }
104
104
 
@@ -139,12 +139,18 @@ export function detectQuestion(message) {
139
139
  }
140
140
 
141
141
  /**
142
- * Check if a message has a CYCLIST reflector marker
142
+ * Check if a message has ANY valid CYCLIST reflector marker
143
143
  * @param {string} message - The message to check
144
- * @returns {boolean} True if a marker is present
144
+ * @returns {boolean} True if any marker is present
145
145
  */
146
146
  export function hasReflectorMarker(message) {
147
- return QUESTION_MARKER_PATTERN.test(message) || CHOICES_MARKER_PATTERN.test(message);
147
+ return (
148
+ QUESTION_MARKER_PATTERN.test(message) ||
149
+ CHOICES_MARKER_PATTERN.test(message) ||
150
+ HANDOFF_MARKER_PATTERN.test(message) ||
151
+ CONTEXT_CLEAR_MARKER_PATTERN.test(message) ||
152
+ CONTINUE_MARKER_PATTERN.test(message)
153
+ );
148
154
  }
149
155
 
150
156
  /**
@@ -154,8 +160,11 @@ export function hasReflectorMarker(message) {
154
160
  */
155
161
  export function extractLastAssistantMessage(transcript) {
156
162
  // Find the last assistant message (reverse order)
163
+ // Claude Code transcript format wraps messages: { message: { role, content }, type, ... }
157
164
  for (let i = transcript.length - 1; i >= 0; i--) {
158
- const msg = transcript[i];
165
+ const entry = transcript[i];
166
+ // Support both wrapped format (Claude Code JSONL) and direct format (tests)
167
+ const msg = entry.message || entry;
159
168
  if (msg.role === 'assistant') {
160
169
  // Handle content as string or array
161
170
  if (typeof msg.content === 'string') {
@@ -176,32 +185,40 @@ export function extractLastAssistantMessage(transcript) {
176
185
 
177
186
  /**
178
187
  * Build the block reason message
179
- * @param {string} questionType - The type of question detected
188
+ * @param {string} questionType - The type of question detected (or empty for general)
180
189
  * @returns {string} The reason message
181
190
  */
182
191
  function buildBlockReason(questionType) {
183
- let reason = 'You asked a question but did not emit a CYCLIST reflector marker. ';
184
-
185
- switch (questionType) {
186
- case 'direct':
187
- reason += 'Add <!-- CYCLIST:QUESTION:yesno --> for yes/no questions or <!-- CYCLIST:QUESTION:open --> for open-ended questions before your question.';
188
- break;
189
- case 'implicit':
190
- reason += 'Add <!-- CYCLIST:QUESTION:yesno --> before phrases like "would you like" or "should I".';
191
- break;
192
- case 'choices':
193
- reason += 'Add <!-- CYCLIST:CHOICES:option1,option2,option3 --> listing the choices before presenting options.';
194
- break;
195
- default:
196
- reason += 'Add the appropriate marker before your question.';
192
+ let reason = 'Every turn MUST end with a CYCLIST reflector marker. ';
193
+
194
+ if (questionType) {
195
+ // Specific question type detected
196
+ switch (questionType) {
197
+ case 'direct':
198
+ reason += 'You asked a question. Add <!-- CYCLIST:QUESTION:yesno --> or <!-- CYCLIST:QUESTION:open --> before your question.';
199
+ break;
200
+ case 'implicit':
201
+ reason += 'You asked an implicit question. Add <!-- CYCLIST:QUESTION:yesno --> before phrases like "would you like" or "should I".';
202
+ break;
203
+ case 'choices':
204
+ reason += 'You offered choices. Add <!-- CYCLIST:CHOICES:option1,option2,option3 --> listing the choices.';
205
+ break;
206
+ }
207
+ } else {
208
+ // No question detected, but still need a marker
209
+ reason += 'Valid markers:\n';
210
+ reason += ' <!-- CYCLIST:HANDOFF:/agent --> - workflow handoff\n';
211
+ reason += ' <!-- CYCLIST:QUESTION:yesno --> - yes/no question\n';
212
+ reason += ' <!-- CYCLIST:QUESTION:open --> - open question\n';
213
+ reason += ' <!-- CYCLIST:CHOICES:a,b,c --> - multiple choice\n';
214
+ reason += ' <!-- CYCLIST:CONTINUE --> - status update, user may continue or redirect';
197
215
  }
198
216
 
199
- reason += ' Re-state your question with the appropriate marker.';
200
217
  return reason;
201
218
  }
202
219
 
203
220
  /**
204
- * Main check for Stop hook - validates question reflector markers
221
+ * Main check for Stop hook - validates ALL turns have reflector markers
205
222
  * @param {object} input - Hook input with transcript_path, stop_hook_active
206
223
  * @param {object} config - Config with workflow settings
207
224
  * @param {string} lastMessage - The last assistant message (pre-extracted for testing)
@@ -218,26 +235,22 @@ export function checkQuestionReflector(input, config, lastMessage) {
218
235
  return { ok: true };
219
236
  }
220
237
 
221
- // If no message, allow
238
+ // If no message, allow (edge case - shouldn't happen)
222
239
  if (!lastMessage) {
223
240
  return { ok: true };
224
241
  }
225
242
 
226
- // If marker present, allow
243
+ // If ANY marker present, allow
227
244
  if (hasReflectorMarker(lastMessage)) {
228
245
  return { ok: true };
229
246
  }
230
247
 
231
- // Check for questions
248
+ // No marker found - block
249
+ // Check if it's a question to give more specific guidance
232
250
  const detection = detectQuestion(lastMessage);
233
- if (!detection.detected) {
234
- return { ok: true };
235
- }
236
-
237
- // Question detected without marker - block
238
251
  return {
239
252
  decision: 'block',
240
- reason: buildBlockReason(detection.type),
253
+ reason: buildBlockReason(detection.detected ? detection.type : ''),
241
254
  };
242
255
  }
243
256
 
@@ -2,8 +2,8 @@
2
2
  #
3
3
  # Question Reflector Enforcement Hook (Stop hook / PreToolUse hook)
4
4
  #
5
- # Thin wrapper that delegates to question-reflector-check.mjs for the actual logic.
6
- # This allows the hook to be written in JavaScript for easier testing and maintenance.
5
+ # Thin wrapper that delegates to question_reflector_check.py for the actual logic.
6
+ # This allows the hook to be written in Python for easier testing and maintenance.
7
7
  #
8
8
  # Input (stdin): JSON with transcript_path, stop_hook_active, etc.
9
9
  # Output (stdout): JSON decision to allow or block
@@ -16,5 +16,5 @@ set -euo pipefail
16
16
  # Get the directory where this script lives
17
17
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
18
 
19
- # Delegate to JavaScript implementation
20
- exec node "$SCRIPT_DIR/question-reflector-check.mjs"
19
+ # Delegate to Python implementation
20
+ exec python3 "$SCRIPT_DIR/question_reflector_check.py"
@@ -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.)