@pennyfarthing/core 7.6.1 → 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 (109) 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 +205 -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 +122 -45
  22. package/pennyfarthing-dist/agents/sm.md +204 -545
  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 +45 -17
  28. package/pennyfarthing-dist/commands/benchmark.md +19 -1
  29. package/pennyfarthing-dist/commands/continue-session.md +1 -1
  30. package/pennyfarthing-dist/commands/git-cleanup.md +43 -308
  31. package/pennyfarthing-dist/commands/solo.md +36 -0
  32. package/pennyfarthing-dist/commands/theme-maker.md +5 -5
  33. package/pennyfarthing-dist/commands/work.md +1 -1
  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/guides/xml-tags.md +335 -0
  39. package/pennyfarthing-dist/personas/themes/gilligans-island.yaml +83 -83
  40. package/pennyfarthing-dist/personas/themes/star-trek-tos.yaml +1 -1
  41. package/pennyfarthing-dist/personas/themes/the-expanse.yaml +11 -11
  42. package/pennyfarthing-dist/scripts/core/agent-session.sh +13 -7
  43. package/pennyfarthing-dist/scripts/core/check-context.sh +9 -1
  44. package/pennyfarthing-dist/scripts/core/handoff-marker.sh +13 -2
  45. package/pennyfarthing-dist/scripts/core/prime.sh +3 -132
  46. package/pennyfarthing-dist/scripts/core/run.sh +9 -0
  47. package/pennyfarthing-dist/scripts/git/create-feature-branches.sh +45 -4
  48. package/pennyfarthing-dist/scripts/git/git-status-all.sh +32 -7
  49. package/pennyfarthing-dist/scripts/hooks/__pycache__/question_reflector_check.cpython-314.pyc +0 -0
  50. package/pennyfarthing-dist/scripts/hooks/bell-mode-hook.sh +30 -11
  51. package/pennyfarthing-dist/scripts/hooks/pre-commit.sh +80 -23
  52. package/pennyfarthing-dist/scripts/hooks/question-reflector-check.sh +4 -4
  53. package/pennyfarthing-dist/scripts/hooks/question_reflector_check.py +499 -0
  54. package/pennyfarthing-dist/scripts/hooks/session-stop.sh +7 -0
  55. package/pennyfarthing-dist/scripts/hooks/welcome-hook.sh +94 -0
  56. package/pennyfarthing-dist/scripts/jira/README.md +10 -7
  57. package/pennyfarthing-dist/scripts/jira/jira-claim-story.sh +10 -152
  58. package/pennyfarthing-dist/scripts/jira/jira-sync-story.sh +14 -4
  59. package/pennyfarthing-dist/scripts/jira/jira-sync.sh +12 -4
  60. package/pennyfarthing-dist/scripts/jira/sync-epic-jira.sh +11 -99
  61. package/pennyfarthing-dist/scripts/lib/common.sh +55 -0
  62. package/pennyfarthing-dist/scripts/maintenance/sidecar-health.sh +97 -0
  63. package/pennyfarthing-dist/scripts/misc/add-short-names.sh +13 -0
  64. package/pennyfarthing-dist/scripts/misc/add_short_names.py +226 -0
  65. package/pennyfarthing-dist/scripts/misc/migrate-bmad-workflow.sh +6 -5
  66. package/pennyfarthing-dist/scripts/misc/migrate_bmad_workflow.py +319 -0
  67. package/pennyfarthing-dist/scripts/misc/statusline.sh +27 -22
  68. package/pennyfarthing-dist/scripts/sprint/import-epic-to-future.sh +6 -5
  69. package/pennyfarthing-dist/scripts/sprint/import_epic_to_future.py +270 -0
  70. package/pennyfarthing-dist/scripts/story/create-story.sh +14 -154
  71. package/pennyfarthing-dist/scripts/story/size-story.sh +12 -192
  72. package/pennyfarthing-dist/scripts/story/story-template.sh +12 -156
  73. package/pennyfarthing-dist/scripts/test/ensure-swebench-data.sh +59 -0
  74. package/pennyfarthing-dist/scripts/test/ground-truth-judge.py +24 -93
  75. package/pennyfarthing-dist/scripts/test/swebench-judge.py +33 -59
  76. package/pennyfarthing-dist/scripts/theme/compute-theme-tiers.sh +8 -6
  77. package/pennyfarthing-dist/scripts/theme/compute_theme_tiers.py +402 -0
  78. package/pennyfarthing-dist/scripts/validation/validate-agent-schema.sh +575 -0
  79. package/pennyfarthing-dist/scripts/workflow/check.py +502 -0
  80. package/pennyfarthing-dist/scripts/workflow/check.sh +3 -476
  81. package/pennyfarthing-dist/scripts/workflow/get-workflow-type.py +61 -0
  82. package/pennyfarthing-dist/scripts/workflow/get-workflow-type.sh +13 -0
  83. package/pennyfarthing-dist/skills/judge/SKILL.md +57 -0
  84. package/pennyfarthing-dist/skills/skill-registry.yaml +52 -16
  85. package/pennyfarthing-dist/skills/sprint/scripts/sync-epic-jira.sh +4 -22
  86. package/pennyfarthing-dist/skills/sprint/skill.md +1 -1
  87. package/pennyfarthing-dist/templates/settings.local.json.template +11 -0
  88. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-01-analyze.md +83 -0
  89. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-02-categorize.md +116 -0
  90. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-03-execute.md +210 -0
  91. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-04-verify.md +88 -0
  92. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-05-complete.md +71 -0
  93. package/pennyfarthing-dist/workflows/git-cleanup.yaml +59 -0
  94. package/pennyfarthing-dist/guides/XML-TAGS.md +0 -156
  95. package/pennyfarthing-dist/scripts/hooks/question-reflector-check.mjs +0 -380
  96. package/pennyfarthing-dist/scripts/hooks/tests/question-reflector.test.mjs +0 -545
  97. package/pennyfarthing-dist/scripts/jira/jira-bidirectional-sync.mjs +0 -327
  98. package/pennyfarthing-dist/scripts/jira/jira-bidirectional-sync.test.mjs +0 -503
  99. package/pennyfarthing-dist/scripts/jira/jira-lib.mjs +0 -443
  100. package/pennyfarthing-dist/scripts/jira/jira-sync-story.mjs +0 -208
  101. package/pennyfarthing-dist/scripts/jira/jira-sync.mjs +0 -198
  102. package/pennyfarthing-dist/scripts/misc/add-short-names.mjs +0 -264
  103. package/pennyfarthing-dist/scripts/misc/migrate-bmad-workflow.mjs +0 -474
  104. package/pennyfarthing-dist/scripts/sprint/import-epic-to-future.mjs +0 -377
  105. package/pennyfarthing-dist/scripts/theme/compute-theme-tiers.js +0 -492
  106. /package/pennyfarthing-dist/guides/{AGENT-COORDINATION.md → agent-coordination.md} +0 -0
  107. /package/pennyfarthing-dist/guides/{HOOKS.md → hooks.md} +0 -0
  108. /package/pennyfarthing-dist/guides/{PROMPT-PATTERNS.md → prompt-patterns.md} +0 -0
  109. /package/pennyfarthing-dist/guides/{SESSION-ARTIFACTS.md → session-artifacts.md} +0 -0
@@ -1,8 +1,10 @@
1
1
  #!/bin/bash
2
- # pre-commit.sh - Git hook to enforce branch protection
2
+ # pre-commit.sh - Git hook to enforce branch protection and agent validation
3
3
  #
4
- # Prevents direct commits to protected branches (main, develop).
5
- # Exception: sprint/ folder commits allowed on develop for administrative tracking.
4
+ # Checks:
5
+ # 1. Prevents direct commits to protected branches (main, develop)
6
+ # Exception: sprint/ folder commits allowed on develop
7
+ # 2. Validates agent files when pennyfarthing-dist/agents/*.md is modified
6
8
  #
7
9
  # Installation:
8
10
  # Installed to .git/hooks/pre-commit by pennyfarthing init or doctor --fix
@@ -10,6 +12,19 @@
10
12
 
11
13
  set -uo pipefail
12
14
 
15
+ # Find project root
16
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17
+ # Handle both direct execution and symlink from .git/hooks
18
+ if [[ "$SCRIPT_DIR" == *".git/hooks"* ]]; then
19
+ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
20
+ else
21
+ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
22
+ fi
23
+
24
+ # =============================================================================
25
+ # Check 1: Branch Protection
26
+ # =============================================================================
27
+
13
28
  BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
14
29
  PROTECTED_BRANCHES="^(main|develop)$"
15
30
 
@@ -21,30 +36,72 @@ if [[ $BRANCH =~ $PROTECTED_BRANCHES ]]; then
21
36
 
22
37
  # If all staged files are in sprint/, allow the commit
23
38
  if [ -z "$NON_SPRINT_FILES" ] && [ -n "$STAGED_FILES" ]; then
24
- exit 0
39
+ # Continue to agent validation check
40
+ :
41
+ else
42
+ echo ""
43
+ echo "COMMIT BLOCKED"
44
+ echo ""
45
+ echo "You are trying to commit directly to: $BRANCH"
46
+ echo "This violates the git workflow rules."
47
+ echo ""
48
+ echo "Protected branches: main, develop"
49
+ echo ""
50
+ echo "What to do:"
51
+ echo "1. Create a feature branch:"
52
+ echo " git checkout -b <type>/<epic-story>-<description>"
53
+ echo ""
54
+ echo "2. Example:"
55
+ echo " git checkout -b feat/8-2-add-authentication"
56
+ echo ""
57
+ echo "3. Then commit your changes on the feature branch"
58
+ echo ""
59
+ echo "Exception: Sprint tracking files (sprint/*) can be committed to develop"
60
+ echo ""
61
+ exit 1
25
62
  fi
63
+ else
64
+ echo ""
65
+ echo "COMMIT BLOCKED - Cannot commit directly to $BRANCH"
66
+ echo ""
67
+ exit 1
26
68
  fi
69
+ fi
27
70
 
71
+ # =============================================================================
72
+ # Check 2: Agent File Validation
73
+ # =============================================================================
74
+
75
+ STAGED_FILES=$(git diff --cached --name-only 2>/dev/null || true)
76
+ AGENT_FILES=$(echo "$STAGED_FILES" | grep "^pennyfarthing-dist/agents/.*\.md$" || true)
77
+
78
+ if [[ -n "$AGENT_FILES" ]]; then
79
+ echo "Agent files staged for commit:"
80
+ echo "$AGENT_FILES" | sed 's/^/ /'
28
81
  echo ""
29
- echo "COMMIT BLOCKED"
30
- echo ""
31
- echo "You are trying to commit directly to: $BRANCH"
32
- echo "This violates the git workflow rules."
33
- echo ""
34
- echo "Protected branches: main, develop"
35
- echo ""
36
- echo "What to do:"
37
- echo "1. Create a feature branch:"
38
- echo " git checkout -b <type>/<epic-story>-<description>"
39
- echo ""
40
- echo "2. Example:"
41
- echo " git checkout -b feat/8-2-add-authentication"
42
- echo ""
43
- echo "3. Then commit your changes on the feature branch"
44
- echo ""
45
- echo "Exception: Sprint tracking files (sprint/*) can be committed to develop"
46
- echo ""
47
- exit 1
82
+
83
+ VALIDATOR="$PROJECT_ROOT/pennyfarthing-dist/scripts/validation/validate-agent-schema.sh"
84
+
85
+ if [[ -x "$VALIDATOR" ]]; then
86
+ echo "Running agent schema validation..."
87
+ echo ""
88
+
89
+ if ! "$VALIDATOR"; then
90
+ echo ""
91
+ echo "COMMIT BLOCKED - Agent validation failed"
92
+ echo ""
93
+ echo "Fix the validation errors above and try again."
94
+ echo "Run 'just validate-agents -v' for detailed output."
95
+ echo ""
96
+ exit 1
97
+ fi
98
+
99
+ echo ""
100
+ echo "✓ Agent validation passed"
101
+ else
102
+ echo "Warning: Agent validator not found at $VALIDATOR"
103
+ echo "Skipping agent validation."
104
+ fi
48
105
  fi
49
106
 
50
107
  exit 0
@@ -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,499 @@
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
+ # 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
+
85
+
86
+ # =============================================================================
87
+ # Helper Functions
88
+ # =============================================================================
89
+
90
+ def strip_code_blocks(text: str) -> str:
91
+ """Strip fenced code blocks from text to avoid false positives.
92
+
93
+ Args:
94
+ text: The text to process
95
+
96
+ Returns:
97
+ Text with code blocks removed
98
+ """
99
+ # Remove fenced code blocks (```...```)
100
+ result = re.sub(r'```[\s\S]*?```', '', text)
101
+ # Remove inline code (`...`)
102
+ result = re.sub(r'`[^`]+`', '', result)
103
+ return result
104
+
105
+
106
+ # =============================================================================
107
+ # Exported Functions
108
+ # =============================================================================
109
+
110
+ def should_skip_enforcement(config: dict[str, Any]) -> bool:
111
+ """Check if enforcement should be skipped based on config.
112
+
113
+ Args:
114
+ config: The config object with workflow settings
115
+
116
+ Returns:
117
+ True if enforcement should be skipped
118
+ """
119
+ # Skip enforcement in CLI mode - markers are only needed for Cyclist UI
120
+ # Cyclist sets CYCLIST=1 in the environment when spawning Claude
121
+ if os.environ.get('CYCLIST') != '1':
122
+ return True
123
+
124
+ # In Cyclist mode, never skip enforcement - markers must always be emitted.
125
+ # relay_mode only controls whether Cyclist auto-executes markers
126
+ # vs showing buttons to the user.
127
+ return False
128
+
129
+
130
+ def detect_question(message: str) -> dict[str, Any]:
131
+ """Detect if a message contains a question.
132
+
133
+ Args:
134
+ message: The message to check
135
+
136
+ Returns:
137
+ Detection result with 'detected' bool and 'type' string
138
+ """
139
+ # Strip code blocks first
140
+ clean_message = strip_code_blocks(message)
141
+
142
+ # Check for rhetorical patterns - if found, not a real question
143
+ if RHETORICAL_PATTERNS.search(clean_message):
144
+ return {'detected': False, 'type': ''}
145
+
146
+ # Check for direct questions (with ?)
147
+ if DIRECT_QUESTION_PATTERN.search(clean_message):
148
+ return {'detected': True, 'type': 'direct'}
149
+
150
+ # Check for implicit questions
151
+ for pattern in IMPLICIT_PATTERNS:
152
+ if pattern.search(clean_message):
153
+ return {'detected': True, 'type': 'implicit'}
154
+
155
+ # Check for choice offerings
156
+ for pattern in CHOICE_PATTERNS:
157
+ if pattern.search(clean_message):
158
+ return {'detected': True, 'type': 'choices'}
159
+
160
+ return {'detected': False, 'type': ''}
161
+
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
+
212
+ def has_reflector_marker(message: str) -> bool:
213
+ """Check if a message has ANY valid CYCLIST reflector marker.
214
+
215
+ Args:
216
+ message: The message to check
217
+
218
+ Returns:
219
+ True if any marker is present
220
+ """
221
+ return bool(
222
+ QUESTION_MARKER_PATTERN.search(message) or
223
+ CHOICES_MARKER_PATTERN.search(message) or
224
+ HANDOFF_MARKER_PATTERN.search(message) or
225
+ CONTEXT_CLEAR_MARKER_PATTERN.search(message) or
226
+ CONTINUE_MARKER_PATTERN.search(message)
227
+ )
228
+
229
+
230
+ def extract_last_assistant_message(transcript: list[dict[str, Any]]) -> str:
231
+ """Extract the last assistant message from a transcript.
232
+
233
+ Args:
234
+ transcript: Array of message objects
235
+
236
+ Returns:
237
+ The last assistant message content
238
+ """
239
+ # Find the last assistant message (reverse order)
240
+ # Claude Code transcript format wraps messages: { message: { role, content }, type, ... }
241
+ for entry in reversed(transcript):
242
+ # Support both wrapped format (Claude Code JSONL) and direct format (tests)
243
+ msg = entry.get('message', entry)
244
+ if msg.get('role') == 'assistant':
245
+ content = msg.get('content', '')
246
+ # Handle content as string or array
247
+ if isinstance(content, str):
248
+ return content
249
+ if isinstance(content, list):
250
+ # Extract text from text blocks, skip tool_use blocks
251
+ return ''.join(
252
+ block.get('text', '')
253
+ for block in content
254
+ if block.get('type') == 'text'
255
+ )
256
+ return ''
257
+ return ''
258
+
259
+
260
+ def build_block_reason(question_type: str, handoff_without_task: bool = False) -> str:
261
+ """Build the block reason message.
262
+
263
+ Provides actionable guidance so Claude can emit JUST the marker
264
+ on retry rather than regenerating the entire response.
265
+
266
+ Args:
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
269
+
270
+ Returns:
271
+ The reason message with suggested marker
272
+ """
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'
285
+
286
+ if question_type:
287
+ # Specific question type detected - suggest exact marker
288
+ if question_type == 'direct':
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)'
292
+ elif question_type == 'implicit':
293
+ reason += 'Detected: implicit question (would you like, should I, etc.)\n'
294
+ reason += 'APPEND THIS: <!-- CYCLIST:QUESTION:yesno -->'
295
+ elif question_type == 'choices':
296
+ reason += 'Detected: choice offering\n'
297
+ reason += 'APPEND THIS: <!-- CYCLIST:CHOICES:option1,option2 -->\n'
298
+ reason += '(Replace option1,option2 with actual choices)'
299
+ else:
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'
304
+ reason += ' <!-- CYCLIST:HANDOFF:/agent --> - workflow handoff\n'
305
+ reason += ' <!-- CYCLIST:QUESTION:yesno --> - yes/no question\n'
306
+ reason += ' <!-- CYCLIST:QUESTION:open --> - open question'
307
+
308
+ return reason
309
+
310
+
311
+ def check_question_reflector(
312
+ input_data: dict[str, Any],
313
+ config: dict[str, Any],
314
+ last_message: str,
315
+ transcript: list[dict[str, Any]] | None = None
316
+ ) -> dict[str, Any]:
317
+ """Main check for Stop hook - validates ALL turns have reflector markers.
318
+
319
+ Also enforces handoff compliance: if handoff language detected without
320
+ Task tool usage, blocks the turn.
321
+
322
+ Args:
323
+ input_data: Hook input with transcript_path, stop_hook_active
324
+ config: Config with workflow settings
325
+ last_message: The last assistant message (pre-extracted for testing)
326
+ transcript: Full transcript for checking Task tool usage
327
+
328
+ Returns:
329
+ { 'ok': True } or { 'decision': 'block', 'reason': str }
330
+ """
331
+ # Prevent infinite loops
332
+ if input_data.get('stop_hook_active'):
333
+ return {'ok': True}
334
+
335
+ # Skip enforcement in relay/turbo mode
336
+ if should_skip_enforcement(config):
337
+ return {'ok': True}
338
+
339
+ # If no message, allow (edge case - shouldn't happen)
340
+ if not last_message:
341
+ return {'ok': True}
342
+
343
+ # If ANY marker present, allow
344
+ if has_reflector_marker(last_message):
345
+ return {'ok': True}
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
+
357
+ # No marker found - block
358
+ # Check if it's a question to give more specific guidance
359
+ detection = detect_question(last_message)
360
+ return {
361
+ 'decision': 'block',
362
+ 'reason': build_block_reason(detection['type'] if detection['detected'] else ''),
363
+ }
364
+
365
+
366
+ def check_ask_user_question(
367
+ input_data: dict[str, Any],
368
+ config: dict[str, Any],
369
+ recent_output: str = ''
370
+ ) -> dict[str, Any]:
371
+ """Check for AskUserQuestion PreToolUse hook.
372
+
373
+ Args:
374
+ input_data: Hook input with tool_name, tool_input
375
+ config: Config with workflow settings
376
+ recent_output: Recent assistant output to check for marker
377
+
378
+ Returns:
379
+ { 'ok': True } or { 'decision': 'block', 'reason': str }
380
+ """
381
+ # Skip enforcement in relay/turbo mode
382
+ if should_skip_enforcement(config):
383
+ return {'ok': True}
384
+
385
+ # If marker present in recent output, allow
386
+ if recent_output and has_reflector_marker(recent_output):
387
+ return {'ok': True}
388
+
389
+ # Block - AskUserQuestion requires a marker
390
+ return {
391
+ 'decision': 'block',
392
+ 'reason': 'AskUserQuestion tool requires a CYCLIST marker. Add <!-- CYCLIST:QUESTION:yesno -->, <!-- CYCLIST:QUESTION:open -->, or <!-- CYCLIST:CHOICES:... --> before using this tool.',
393
+ }
394
+
395
+
396
+ # =============================================================================
397
+ # CLI Entry Point (for bash wrapper)
398
+ # =============================================================================
399
+
400
+ def load_config(project_dir: str) -> dict[str, Any]:
401
+ """Load config from .pennyfarthing/config.local.yaml.
402
+
403
+ Args:
404
+ project_dir: The project directory
405
+
406
+ Returns:
407
+ The config object
408
+ """
409
+ try:
410
+ config_path = Path(project_dir) / '.pennyfarthing' / 'config.local.yaml'
411
+ content = config_path.read_text()
412
+ # Simple YAML parsing for the fields we need
413
+ config: dict[str, Any] = {'workflow': {}}
414
+
415
+ # Extract permission_mode
416
+ mode_match = re.search(r'permission_mode:\s*(\w+)', content)
417
+ if mode_match:
418
+ config['workflow']['permission_mode'] = mode_match.group(1)
419
+
420
+ # Extract relay_mode
421
+ relay_match = re.search(r'relay_mode:\s*(true|false)', content)
422
+ if relay_match:
423
+ config['workflow']['relay_mode'] = relay_match.group(1) == 'true'
424
+
425
+ return config
426
+ except Exception:
427
+ # Default config if file doesn't exist
428
+ return {'workflow': {'permission_mode': 'manual'}}
429
+
430
+
431
+ def read_transcript(transcript_path: str) -> tuple[str, list[dict[str, Any]]]:
432
+ """Read transcript and extract last assistant message.
433
+
434
+ Args:
435
+ transcript_path: Path to JSONL transcript
436
+
437
+ Returns:
438
+ Tuple of (last assistant message, full transcript)
439
+ """
440
+ try:
441
+ content = Path(transcript_path).read_text()
442
+ lines = [line for line in content.strip().split('\n') if line]
443
+
444
+ # Parse JSONL and build transcript array
445
+ transcript: list[dict[str, Any]] = []
446
+ for line in lines:
447
+ try:
448
+ transcript.append(json.loads(line))
449
+ except json.JSONDecodeError:
450
+ # Skip malformed lines
451
+ pass
452
+
453
+ return extract_last_assistant_message(transcript), transcript
454
+ except Exception:
455
+ return '', []
456
+
457
+
458
+ def main() -> None:
459
+ """Main CLI entry point."""
460
+ # Read input from stdin
461
+ input_data_str = sys.stdin.read()
462
+
463
+ try:
464
+ input_data = json.loads(input_data_str)
465
+ except json.JSONDecodeError:
466
+ # Invalid input - allow to prevent breaking
467
+ print(json.dumps({'ok': True}))
468
+ sys.exit(0)
469
+
470
+ # Determine project directory
471
+ project_dir = os.environ.get('CLAUDE_PROJECT_DIR', os.getcwd())
472
+
473
+ # Load config
474
+ config = load_config(project_dir)
475
+
476
+ # Determine hook type based on input
477
+ if input_data.get('tool_name') == 'AskUserQuestion':
478
+ # PreToolUse hook for AskUserQuestion
479
+ # For PreToolUse, we'd need the recent output - for now, just check config
480
+ result = check_ask_user_question(input_data, config, '')
481
+ print(json.dumps(result))
482
+ else:
483
+ # Stop hook
484
+ transcript_path = input_data.get('transcript_path', '')
485
+ last_message, transcript = read_transcript(transcript_path) if transcript_path else ('', [])
486
+ result = check_question_reflector(input_data, config, last_message, transcript)
487
+ print(json.dumps(result))
488
+
489
+ sys.exit(0)
490
+
491
+
492
+ if __name__ == '__main__':
493
+ try:
494
+ main()
495
+ except Exception as err:
496
+ print(str(err), file=sys.stderr)
497
+ # On error, allow to prevent breaking
498
+ print(json.dumps({'ok': True}))
499
+ 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.)