@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.
- package/README.md +109 -201
- 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 +91 -0
- package/packages/core/dist/cli/commands/doctor.js.map +1 -1
- package/packages/core/dist/cli/commands/init.js +31 -0
- package/packages/core/dist/cli/commands/init.js.map +1 -1
- package/packages/core/dist/cli/commands/update.js +31 -0
- package/packages/core/dist/cli/commands/update.js.map +1 -1
- package/pennyfarthing-dist/agents/architect.md +48 -53
- package/pennyfarthing-dist/agents/dev.md +74 -164
- package/pennyfarthing-dist/agents/devops.md +44 -39
- package/pennyfarthing-dist/agents/handoff.md +46 -23
- package/pennyfarthing-dist/agents/orchestrator.md +84 -255
- package/pennyfarthing-dist/agents/pm.md +40 -50
- package/pennyfarthing-dist/agents/reviewer-preflight.md +58 -26
- package/pennyfarthing-dist/agents/reviewer.md +107 -298
- package/pennyfarthing-dist/agents/sm-file-summary.md +51 -30
- package/pennyfarthing-dist/agents/sm-finish.md +59 -38
- package/pennyfarthing-dist/agents/sm-handoff.md +40 -33
- package/pennyfarthing-dist/agents/sm-setup.md +89 -47
- package/pennyfarthing-dist/agents/sm.md +171 -558
- package/pennyfarthing-dist/agents/tea.md +77 -146
- package/pennyfarthing-dist/agents/tech-writer.md +43 -24
- package/pennyfarthing-dist/agents/testing-runner.md +73 -30
- package/pennyfarthing-dist/agents/ux-designer.md +39 -25
- package/pennyfarthing-dist/agents/workflow-status-check.md +34 -16
- package/pennyfarthing-dist/commands/benchmark.md +19 -1
- package/pennyfarthing-dist/commands/continue-session.md +1 -1
- package/pennyfarthing-dist/commands/solo.md +5 -0
- package/pennyfarthing-dist/commands/theme-maker.md +5 -5
- package/pennyfarthing-dist/commands/work.md +1 -1
- package/pennyfarthing-dist/guides/XML-TAGS.md +179 -0
- package/pennyfarthing-dist/guides/agent-behavior.md +22 -9
- package/pennyfarthing-dist/guides/agent-tag-taxonomy.md +432 -0
- package/pennyfarthing-dist/guides/patterns/approval-gates-pattern.md +27 -7
- package/pennyfarthing-dist/guides/scale-levels.md +114 -0
- package/pennyfarthing-dist/personas/themes/gilligans-island.yaml +2 -2
- package/pennyfarthing-dist/personas/themes/star-trek-tos.yaml +1 -1
- package/pennyfarthing-dist/scripts/core/agent-session.sh +13 -7
- package/pennyfarthing-dist/scripts/core/check-context.sh +6 -1
- package/pennyfarthing-dist/scripts/core/prime.sh +57 -32
- package/pennyfarthing-dist/scripts/git/create-feature-branches.sh +45 -4
- package/pennyfarthing-dist/scripts/git/git-status-all.sh +32 -7
- package/pennyfarthing-dist/scripts/hooks/bell-mode-hook.sh +30 -11
- package/pennyfarthing-dist/scripts/hooks/pre-commit.sh +80 -23
- package/pennyfarthing-dist/scripts/hooks/question-reflector-check.mjs +66 -53
- package/pennyfarthing-dist/scripts/hooks/question-reflector-check.sh +4 -4
- package/pennyfarthing-dist/scripts/hooks/question_reflector_check.py +402 -0
- package/pennyfarthing-dist/scripts/hooks/session-stop.sh +7 -0
- package/pennyfarthing-dist/scripts/hooks/welcome-hook.sh +94 -0
- package/pennyfarthing-dist/scripts/jira/jira-claim-story.sh +10 -152
- package/pennyfarthing-dist/scripts/jira/jira-sync-story.sh +14 -4
- package/pennyfarthing-dist/scripts/jira/jira-sync.sh +12 -4
- package/pennyfarthing-dist/scripts/jira/sync-epic-jira.sh +11 -99
- package/pennyfarthing-dist/scripts/lib/common.sh +55 -0
- package/pennyfarthing-dist/scripts/maintenance/sidecar-health.sh +97 -0
- package/pennyfarthing-dist/scripts/misc/statusline.sh +27 -22
- package/pennyfarthing-dist/scripts/story/create-story.sh +14 -154
- package/pennyfarthing-dist/scripts/story/size-story.sh +12 -192
- package/pennyfarthing-dist/scripts/story/story-template.sh +12 -156
- package/pennyfarthing-dist/scripts/test/ground-truth-judge.py +24 -93
- package/pennyfarthing-dist/scripts/test/swebench-judge.py +33 -59
- package/pennyfarthing-dist/scripts/validation/validate-agent-schema.sh +575 -0
- package/pennyfarthing-dist/scripts/workflow/check.py +502 -0
- package/pennyfarthing-dist/skills/skill-registry.yaml +52 -16
- package/pennyfarthing-dist/skills/sprint/skill.md +1 -1
- package/pennyfarthing-dist/templates/settings.local.json.template +11 -0
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* <!-- CYCLIST:
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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
|
|
144
|
+
* @returns {boolean} True if any marker is present
|
|
145
145
|
*/
|
|
146
146
|
export function hasReflectorMarker(message) {
|
|
147
|
-
return
|
|
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
|
|
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 = '
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
6
|
-
# This allows the hook to be written in
|
|
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
|
|
20
|
-
exec
|
|
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.)
|