@pennyfarthing/core 7.6.0 → 7.6.1
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/package.json +1 -1
- package/pennyfarthing-dist/guides/agent-behavior.md +24 -2
- package/pennyfarthing-dist/scripts/core/check-context.sh +19 -7
- package/pennyfarthing-dist/scripts/hooks/question-reflector-check.mjs +380 -0
- package/pennyfarthing-dist/scripts/hooks/question-reflector-check.sh +20 -0
- package/pennyfarthing-dist/scripts/hooks/tests/question-reflector.test.mjs +545 -0
- package/pennyfarthing-dist/scripts/misc/deploy.sh +13 -1
package/package.json
CHANGED
|
@@ -158,14 +158,15 @@ HTML comments that agents emit to signal Cyclist UI. Format: `<!-- CYCLIST:TYPE:
|
|
|
158
158
|
|------|-------|----------------|
|
|
159
159
|
| `HANDOFF` | `/agent` | Shows "Continue with /agent" button |
|
|
160
160
|
| `CONTEXT_CLEAR` | `/agent` | Clears session, reloads with agent |
|
|
161
|
-
| `QUESTION` | `yesno` | Shows
|
|
162
|
-
| `CHOICES` | `
|
|
161
|
+
| `QUESTION` | `yesno` or `open` | Shows input dialog |
|
|
162
|
+
| `CHOICES` | `opt1,opt2,opt3` | Shows choice buttons |
|
|
163
163
|
|
|
164
164
|
**Examples:**
|
|
165
165
|
```
|
|
166
166
|
<!-- CYCLIST:HANDOFF:/tea -->
|
|
167
167
|
<!-- CYCLIST:CONTEXT_CLEAR:/dev -->
|
|
168
168
|
<!-- CYCLIST:QUESTION:yesno -->
|
|
169
|
+
<!-- CYCLIST:QUESTION:open -->
|
|
169
170
|
<!-- CYCLIST:CHOICES:option1,option2,option3 -->
|
|
170
171
|
```
|
|
171
172
|
|
|
@@ -175,6 +176,27 @@ HTML comments that agents emit to signal Cyclist UI. Format: `<!-- CYCLIST:TYPE:
|
|
|
175
176
|
- `QUESTION`/`CHOICES` - User input needed mid-work
|
|
176
177
|
</info>
|
|
177
178
|
|
|
179
|
+
<critical>
|
|
180
|
+
**Question Reflector Enforcement:** A Stop hook validates that ANY question to the user has a reflector marker. Emit the marker BEFORE your question.
|
|
181
|
+
|
|
182
|
+
**Question types requiring markers:**
|
|
183
|
+
- Direct questions ending with `?`
|
|
184
|
+
- Implicit questions: "let me know if...", "would you like...", "should I..."
|
|
185
|
+
- Choice offerings: "Option A or Option B"
|
|
186
|
+
- Requests for input: "what do you think", "your preference"
|
|
187
|
+
- Clarification requests: "could you clarify"
|
|
188
|
+
|
|
189
|
+
**Marker selection:**
|
|
190
|
+
- `<!-- CYCLIST:QUESTION:yesno -->` - Yes/no questions
|
|
191
|
+
- `<!-- CYCLIST:QUESTION:open -->` - Open-ended questions
|
|
192
|
+
- `<!-- CYCLIST:CHOICES:a,b,c -->` - Multiple choice (list options)
|
|
193
|
+
|
|
194
|
+
**Exempt (no marker needed):**
|
|
195
|
+
- Rhetorical questions you answer yourself
|
|
196
|
+
- Questions inside code blocks or examples
|
|
197
|
+
- Historical context ("the question was...")
|
|
198
|
+
</critical>
|
|
199
|
+
|
|
178
200
|
---
|
|
179
201
|
|
|
180
202
|
<agent-exit-protocol>
|
|
@@ -53,6 +53,7 @@ critical_threshold = $DEFAULT_CRITICAL_THRESHOLD
|
|
|
53
53
|
max_tokens = $DEFAULT_MAX_TOKENS
|
|
54
54
|
tirepump_threshold = $DEFAULT_TIREPUMP_THRESHOLD
|
|
55
55
|
permission_mode = 'manual' # Default to manual
|
|
56
|
+
relay_mode = False # MSSCI-12395: Independent auto-handoff toggle
|
|
56
57
|
|
|
57
58
|
# First try .pennyfarthing/config.local.yaml (preferred location)
|
|
58
59
|
try:
|
|
@@ -67,9 +68,13 @@ try:
|
|
|
67
68
|
critical_threshold = cb.get('critical_threshold', critical_threshold)
|
|
68
69
|
max_tokens = cb.get('max_tokens', max_tokens)
|
|
69
70
|
tirepump_threshold = cb.get('tirepump_threshold', tirepump_threshold)
|
|
70
|
-
# Read permission_mode from workflow section
|
|
71
|
-
if 'workflow' in config
|
|
72
|
-
permission_mode
|
|
71
|
+
# Read permission_mode and relay_mode from workflow section
|
|
72
|
+
if 'workflow' in config:
|
|
73
|
+
if 'permission_mode' in config['workflow']:
|
|
74
|
+
permission_mode = config['workflow']['permission_mode']
|
|
75
|
+
# MSSCI-12395: relay_mode for auto-handoff (independent of permission_mode)
|
|
76
|
+
if 'relay_mode' in config['workflow']:
|
|
77
|
+
relay_mode = config['workflow']['relay_mode'] == True
|
|
73
78
|
except:
|
|
74
79
|
# Fallback to settings.local.json (legacy location)
|
|
75
80
|
try:
|
|
@@ -82,8 +87,12 @@ except:
|
|
|
82
87
|
critical_threshold = cb.get('critical_threshold', critical_threshold)
|
|
83
88
|
max_tokens = cb.get('max_tokens', max_tokens)
|
|
84
89
|
tirepump_threshold = cb.get('tirepump_threshold', tirepump_threshold)
|
|
85
|
-
if 'workflow' in settings
|
|
86
|
-
permission_mode
|
|
90
|
+
if 'workflow' in settings:
|
|
91
|
+
if 'permission_mode' in settings['workflow']:
|
|
92
|
+
permission_mode = settings['workflow']['permission_mode']
|
|
93
|
+
# MSSCI-12395: relay_mode for auto-handoff (independent of permission_mode)
|
|
94
|
+
if 'relay_mode' in settings['workflow']:
|
|
95
|
+
relay_mode = settings['workflow']['relay_mode'] == True
|
|
87
96
|
except:
|
|
88
97
|
pass
|
|
89
98
|
|
|
@@ -93,6 +102,7 @@ print(f'CRITICAL_THRESHOLD={critical_threshold}')
|
|
|
93
102
|
print(f'MAX_TOKENS={max_tokens}')
|
|
94
103
|
print(f'TIREPUMP_THRESHOLD={tirepump_threshold}')
|
|
95
104
|
print(f'PERMISSION_MODE={permission_mode}')
|
|
105
|
+
print(f'RELAY_MODE={str(relay_mode).lower()}')
|
|
96
106
|
" 2>/dev/null)
|
|
97
107
|
|
|
98
108
|
# Apply config or use defaults
|
|
@@ -140,6 +150,7 @@ import json
|
|
|
140
150
|
warning_threshold = $WARNING_THRESHOLD
|
|
141
151
|
max_tokens = $MAX_TOKENS
|
|
142
152
|
permission_mode = '$PERMISSION_MODE'
|
|
153
|
+
relay_mode = '$RELAY_MODE' == 'true' # MSSCI-12395: Independent auto-handoff toggle
|
|
143
154
|
tirepump_threshold = $TIREPUMP_THRESHOLD # Threshold for TirePump auto-handoff (configurable)
|
|
144
155
|
|
|
145
156
|
with open('$TRANSCRIPT', 'r') as f:
|
|
@@ -199,10 +210,11 @@ if last_total is not None:
|
|
|
199
210
|
print('HANDOFF_MODE=ask')
|
|
200
211
|
|
|
201
212
|
# TirePump: Use CONTEXT_CLEAR (clear + load next agent) when:
|
|
202
|
-
# 1.
|
|
213
|
+
# 1. relay_mode is true (auto-handoff enabled) - MSSCI-12395
|
|
203
214
|
# 2. context > 60% (tirepump_threshold)
|
|
204
215
|
# This enables continuous autonomous runs without manual intervention
|
|
205
|
-
|
|
216
|
+
# Legacy: also support permission_mode == 'turbo' for backwards compatibility
|
|
217
|
+
use_tirepump = (relay_mode or permission_mode == 'turbo') and usable_pct > tirepump_threshold
|
|
206
218
|
print(f'USE_TIREPUMP={str(use_tirepump).lower()}')
|
|
207
219
|
|
|
208
220
|
# Cyclist detection: Multiple methods for robustness
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* question-reflector-check.mjs - Question reflector enforcement hook
|
|
4
|
+
*
|
|
5
|
+
* Story: MSSCI-12393
|
|
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.
|
|
9
|
+
*
|
|
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 -->
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync } from 'fs';
|
|
22
|
+
import { join, dirname } from 'path';
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Constants
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
// Marker patterns
|
|
29
|
+
const QUESTION_MARKER_PATTERN = /<!--\s*CYCLIST:QUESTION:(yesno|open)\s*-->/i;
|
|
30
|
+
const CHOICES_MARKER_PATTERN = /<!--\s*CYCLIST:CHOICES:[^>]+\s*-->/i;
|
|
31
|
+
|
|
32
|
+
// Question patterns - direct (with ?)
|
|
33
|
+
// Match: end of line, followed by space+capital (new sentence), or followed by newline
|
|
34
|
+
const DIRECT_QUESTION_PATTERN = /\?(\s*$|\s+[A-Z]|\s*\n)/;
|
|
35
|
+
|
|
36
|
+
// Rhetorical patterns to exclude
|
|
37
|
+
const RHETORICAL_PATTERNS = /\b(the question (was|is)|asked whether|wondering if)\b/i;
|
|
38
|
+
|
|
39
|
+
// Implicit question patterns
|
|
40
|
+
const IMPLICIT_PATTERNS = [
|
|
41
|
+
/\bwould you like\b/i,
|
|
42
|
+
/\bshould I\b/i,
|
|
43
|
+
/\bdo you want\b/i,
|
|
44
|
+
/\blet me know if\b/i,
|
|
45
|
+
/\bwhat do you (think|prefer)\b/i,
|
|
46
|
+
/\byour (preference|thoughts)\b/i,
|
|
47
|
+
/\bcould you (clarify|confirm|specify)\b/i,
|
|
48
|
+
/\bwhich (option|approach)\b/i,
|
|
49
|
+
/\bready to proceed\b/i,
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
// Choice offering patterns
|
|
53
|
+
const CHOICE_PATTERNS = [
|
|
54
|
+
/\boption [A-D]\b/i,
|
|
55
|
+
/\bchoice [0-9]\b/i,
|
|
56
|
+
/\bwe could (either|do)\b/i,
|
|
57
|
+
/\balternatively\b/i,
|
|
58
|
+
/\bor would you prefer\b/i,
|
|
59
|
+
/\bpick one\b/i,
|
|
60
|
+
/\bchoose between\b/i,
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
// =============================================================================
|
|
64
|
+
// Helper Functions
|
|
65
|
+
// =============================================================================
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Strip fenced code blocks from text to avoid false positives
|
|
69
|
+
* @param {string} text - The text to process
|
|
70
|
+
* @returns {string} Text with code blocks removed
|
|
71
|
+
*/
|
|
72
|
+
function stripCodeBlocks(text) {
|
|
73
|
+
// Remove fenced code blocks (```...```)
|
|
74
|
+
let result = text.replace(/```[\s\S]*?```/g, '');
|
|
75
|
+
// Remove inline code (`...`)
|
|
76
|
+
result = result.replace(/`[^`]+`/g, '');
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// =============================================================================
|
|
81
|
+
// Exported Functions
|
|
82
|
+
// =============================================================================
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if enforcement should be skipped based on config
|
|
86
|
+
* @param {object} config - The config object with workflow settings
|
|
87
|
+
* @returns {boolean} True if enforcement should be skipped
|
|
88
|
+
*/
|
|
89
|
+
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) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Detect if a message contains a question
|
|
107
|
+
* @param {string} message - The message to check
|
|
108
|
+
* @returns {{ detected: boolean, type: string }} Detection result
|
|
109
|
+
*/
|
|
110
|
+
export function detectQuestion(message) {
|
|
111
|
+
// Strip code blocks first
|
|
112
|
+
const cleanMessage = stripCodeBlocks(message);
|
|
113
|
+
|
|
114
|
+
// Check for rhetorical patterns - if found, not a real question
|
|
115
|
+
if (RHETORICAL_PATTERNS.test(cleanMessage)) {
|
|
116
|
+
return { detected: false, type: '' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check for direct questions (with ?)
|
|
120
|
+
if (DIRECT_QUESTION_PATTERN.test(cleanMessage)) {
|
|
121
|
+
return { detected: true, type: 'direct' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Check for implicit questions
|
|
125
|
+
for (const pattern of IMPLICIT_PATTERNS) {
|
|
126
|
+
if (pattern.test(cleanMessage)) {
|
|
127
|
+
return { detected: true, type: 'implicit' };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check for choice offerings
|
|
132
|
+
for (const pattern of CHOICE_PATTERNS) {
|
|
133
|
+
if (pattern.test(cleanMessage)) {
|
|
134
|
+
return { detected: true, type: 'choices' };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { detected: false, type: '' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Check if a message has a CYCLIST reflector marker
|
|
143
|
+
* @param {string} message - The message to check
|
|
144
|
+
* @returns {boolean} True if a marker is present
|
|
145
|
+
*/
|
|
146
|
+
export function hasReflectorMarker(message) {
|
|
147
|
+
return QUESTION_MARKER_PATTERN.test(message) || CHOICES_MARKER_PATTERN.test(message);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Extract the last assistant message from a transcript
|
|
152
|
+
* @param {Array} transcript - Array of message objects
|
|
153
|
+
* @returns {string} The last assistant message content
|
|
154
|
+
*/
|
|
155
|
+
export function extractLastAssistantMessage(transcript) {
|
|
156
|
+
// Find the last assistant message (reverse order)
|
|
157
|
+
for (let i = transcript.length - 1; i >= 0; i--) {
|
|
158
|
+
const msg = transcript[i];
|
|
159
|
+
if (msg.role === 'assistant') {
|
|
160
|
+
// Handle content as string or array
|
|
161
|
+
if (typeof msg.content === 'string') {
|
|
162
|
+
return msg.content;
|
|
163
|
+
}
|
|
164
|
+
if (Array.isArray(msg.content)) {
|
|
165
|
+
// Extract text from text blocks, skip tool_use blocks
|
|
166
|
+
return msg.content
|
|
167
|
+
.filter(block => block.type === 'text')
|
|
168
|
+
.map(block => block.text)
|
|
169
|
+
.join('');
|
|
170
|
+
}
|
|
171
|
+
return '';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return '';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Build the block reason message
|
|
179
|
+
* @param {string} questionType - The type of question detected
|
|
180
|
+
* @returns {string} The reason message
|
|
181
|
+
*/
|
|
182
|
+
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.';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
reason += ' Re-state your question with the appropriate marker.';
|
|
200
|
+
return reason;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Main check for Stop hook - validates question reflector markers
|
|
205
|
+
* @param {object} input - Hook input with transcript_path, stop_hook_active
|
|
206
|
+
* @param {object} config - Config with workflow settings
|
|
207
|
+
* @param {string} lastMessage - The last assistant message (pre-extracted for testing)
|
|
208
|
+
* @returns {{ ok: true } | { decision: 'block', reason: string }}
|
|
209
|
+
*/
|
|
210
|
+
export function checkQuestionReflector(input, config, lastMessage) {
|
|
211
|
+
// Prevent infinite loops
|
|
212
|
+
if (input.stop_hook_active) {
|
|
213
|
+
return { ok: true };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Skip enforcement in relay/turbo mode
|
|
217
|
+
if (shouldSkipEnforcement(config)) {
|
|
218
|
+
return { ok: true };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// If no message, allow
|
|
222
|
+
if (!lastMessage) {
|
|
223
|
+
return { ok: true };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// If marker present, allow
|
|
227
|
+
if (hasReflectorMarker(lastMessage)) {
|
|
228
|
+
return { ok: true };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Check for questions
|
|
232
|
+
const detection = detectQuestion(lastMessage);
|
|
233
|
+
if (!detection.detected) {
|
|
234
|
+
return { ok: true };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Question detected without marker - block
|
|
238
|
+
return {
|
|
239
|
+
decision: 'block',
|
|
240
|
+
reason: buildBlockReason(detection.type),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Check for AskUserQuestion PreToolUse hook
|
|
246
|
+
* @param {object} input - Hook input with tool_name, tool_input
|
|
247
|
+
* @param {object} config - Config with workflow settings
|
|
248
|
+
* @param {string} [recentOutput] - Recent assistant output to check for marker
|
|
249
|
+
* @returns {{ ok: true } | { decision: 'block', reason: string }}
|
|
250
|
+
*/
|
|
251
|
+
export function checkAskUserQuestion(input, config, recentOutput = '') {
|
|
252
|
+
// Skip enforcement in relay/turbo mode
|
|
253
|
+
if (shouldSkipEnforcement(config)) {
|
|
254
|
+
return { ok: true };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// If marker present in recent output, allow
|
|
258
|
+
if (recentOutput && hasReflectorMarker(recentOutput)) {
|
|
259
|
+
return { ok: true };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Block - AskUserQuestion requires a marker
|
|
263
|
+
return {
|
|
264
|
+
decision: 'block',
|
|
265
|
+
reason: 'AskUserQuestion tool requires a CYCLIST marker. Add <!-- CYCLIST:QUESTION:yesno -->, <!-- CYCLIST:QUESTION:open -->, or <!-- CYCLIST:CHOICES:... --> before using this tool.',
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// =============================================================================
|
|
270
|
+
// CLI Entry Point (for bash wrapper)
|
|
271
|
+
// =============================================================================
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Load config from .pennyfarthing/config.local.yaml
|
|
275
|
+
* @param {string} projectDir - The project directory
|
|
276
|
+
* @returns {object} The config object
|
|
277
|
+
*/
|
|
278
|
+
function loadConfig(projectDir) {
|
|
279
|
+
try {
|
|
280
|
+
const configPath = join(projectDir, '.pennyfarthing', 'config.local.yaml');
|
|
281
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
282
|
+
// Simple YAML parsing for the fields we need
|
|
283
|
+
const config = { workflow: {} };
|
|
284
|
+
|
|
285
|
+
// Extract permission_mode
|
|
286
|
+
const modeMatch = content.match(/permission_mode:\s*(\w+)/);
|
|
287
|
+
if (modeMatch) {
|
|
288
|
+
config.workflow.permission_mode = modeMatch[1];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Extract relay_mode
|
|
292
|
+
const relayMatch = content.match(/relay_mode:\s*(true|false)/);
|
|
293
|
+
if (relayMatch) {
|
|
294
|
+
config.workflow.relay_mode = relayMatch[1] === 'true';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return config;
|
|
298
|
+
} catch {
|
|
299
|
+
// Default config if file doesn't exist
|
|
300
|
+
return { workflow: { permission_mode: 'manual' } };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Read transcript and extract last assistant message
|
|
306
|
+
* @param {string} transcriptPath - Path to JSONL transcript
|
|
307
|
+
* @returns {string} The last assistant message
|
|
308
|
+
*/
|
|
309
|
+
function readTranscript(transcriptPath) {
|
|
310
|
+
try {
|
|
311
|
+
const content = readFileSync(transcriptPath, 'utf-8');
|
|
312
|
+
const lines = content.trim().split('\n').filter(Boolean);
|
|
313
|
+
|
|
314
|
+
// Parse JSONL and build transcript array
|
|
315
|
+
const transcript = [];
|
|
316
|
+
for (const line of lines) {
|
|
317
|
+
try {
|
|
318
|
+
transcript.push(JSON.parse(line));
|
|
319
|
+
} catch {
|
|
320
|
+
// Skip malformed lines
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return extractLastAssistantMessage(transcript);
|
|
325
|
+
} catch {
|
|
326
|
+
return '';
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Main CLI entry point
|
|
332
|
+
*/
|
|
333
|
+
async function main() {
|
|
334
|
+
// Read input from stdin
|
|
335
|
+
let inputData = '';
|
|
336
|
+
for await (const chunk of process.stdin) {
|
|
337
|
+
inputData += chunk;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let input;
|
|
341
|
+
try {
|
|
342
|
+
input = JSON.parse(inputData);
|
|
343
|
+
} catch {
|
|
344
|
+
// Invalid input - allow to prevent breaking
|
|
345
|
+
console.log(JSON.stringify({ ok: true }));
|
|
346
|
+
process.exit(0);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Determine project directory
|
|
350
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
351
|
+
|
|
352
|
+
// Load config
|
|
353
|
+
const config = loadConfig(projectDir);
|
|
354
|
+
|
|
355
|
+
// Determine hook type based on input
|
|
356
|
+
if (input.tool_name === 'AskUserQuestion') {
|
|
357
|
+
// PreToolUse hook for AskUserQuestion
|
|
358
|
+
// For PreToolUse, we'd need the recent output - for now, just check config
|
|
359
|
+
const result = checkAskUserQuestion(input, config, '');
|
|
360
|
+
console.log(JSON.stringify(result));
|
|
361
|
+
} else {
|
|
362
|
+
// Stop hook
|
|
363
|
+
const transcriptPath = input.transcript_path || '';
|
|
364
|
+
const lastMessage = transcriptPath ? readTranscript(transcriptPath) : '';
|
|
365
|
+
const result = checkQuestionReflector(input, config, lastMessage);
|
|
366
|
+
console.log(JSON.stringify(result));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
process.exit(0);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Run if called directly
|
|
373
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
374
|
+
main().catch((err) => {
|
|
375
|
+
console.error(err);
|
|
376
|
+
// On error, allow to prevent breaking
|
|
377
|
+
console.log(JSON.stringify({ ok: true }));
|
|
378
|
+
process.exit(0);
|
|
379
|
+
});
|
|
380
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Question Reflector Enforcement Hook (Stop hook / PreToolUse hook)
|
|
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.
|
|
7
|
+
#
|
|
8
|
+
# Input (stdin): JSON with transcript_path, stop_hook_active, etc.
|
|
9
|
+
# Output (stdout): JSON decision to allow or block
|
|
10
|
+
#
|
|
11
|
+
# Story: MSSCI-12393
|
|
12
|
+
#
|
|
13
|
+
|
|
14
|
+
set -euo pipefail
|
|
15
|
+
|
|
16
|
+
# Get the directory where this script lives
|
|
17
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
18
|
+
|
|
19
|
+
# Delegate to JavaScript implementation
|
|
20
|
+
exec node "$SCRIPT_DIR/question-reflector-check.mjs"
|
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* question-reflector.test.mjs - Tests for question reflector enforcement hook
|
|
4
|
+
*
|
|
5
|
+
* Story: MSSCI-12393
|
|
6
|
+
* TDD Phase: RED
|
|
7
|
+
*
|
|
8
|
+
* Run with: node --test pennyfarthing-dist/scripts/hooks/tests/question-reflector.test.mjs
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, beforeEach, mock } from 'node:test';
|
|
12
|
+
import assert from 'node:assert';
|
|
13
|
+
|
|
14
|
+
// The module under test
|
|
15
|
+
import {
|
|
16
|
+
detectQuestion,
|
|
17
|
+
hasReflectorMarker,
|
|
18
|
+
shouldSkipEnforcement,
|
|
19
|
+
extractLastAssistantMessage,
|
|
20
|
+
checkQuestionReflector,
|
|
21
|
+
checkAskUserQuestion,
|
|
22
|
+
} from '../question-reflector-check.mjs';
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Test Fixtures
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
const MARKERS = {
|
|
29
|
+
yesno: '<!-- CYCLIST:QUESTION:yesno -->',
|
|
30
|
+
open: '<!-- CYCLIST:QUESTION:open -->',
|
|
31
|
+
choices: '<!-- CYCLIST:CHOICES:option1,option2,option3 -->',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const CONFIG_MANUAL = {
|
|
35
|
+
workflow: { permission_mode: 'manual' },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const CONFIG_ACCEPT = {
|
|
39
|
+
workflow: { permission_mode: 'accept' },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const CONFIG_TURBO_LEGACY = {
|
|
43
|
+
workflow: { permission_mode: 'turbo' },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const CONFIG_RELAY_ON = {
|
|
47
|
+
workflow: { permission_mode: 'accept', relay_mode: true },
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const CONFIG_RELAY_OFF = {
|
|
51
|
+
workflow: { permission_mode: 'accept', relay_mode: false },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// =============================================================================
|
|
55
|
+
// Relay/Turbo Mode Bypass Tests
|
|
56
|
+
// =============================================================================
|
|
57
|
+
|
|
58
|
+
describe('shouldSkipEnforcement', () => {
|
|
59
|
+
it('should skip enforcement when permission_mode is turbo (legacy)', () => {
|
|
60
|
+
const result = shouldSkipEnforcement(CONFIG_TURBO_LEGACY);
|
|
61
|
+
assert.strictEqual(result, true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should skip enforcement when relay_mode is true', () => {
|
|
65
|
+
const result = shouldSkipEnforcement(CONFIG_RELAY_ON);
|
|
66
|
+
assert.strictEqual(result, true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should NOT skip enforcement when relay_mode is false', () => {
|
|
70
|
+
const result = shouldSkipEnforcement(CONFIG_RELAY_OFF);
|
|
71
|
+
assert.strictEqual(result, false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should NOT skip enforcement in manual mode without relay', () => {
|
|
75
|
+
const result = shouldSkipEnforcement(CONFIG_MANUAL);
|
|
76
|
+
assert.strictEqual(result, false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should NOT skip enforcement in accept mode without relay', () => {
|
|
80
|
+
const result = shouldSkipEnforcement(CONFIG_ACCEPT);
|
|
81
|
+
assert.strictEqual(result, false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// =============================================================================
|
|
86
|
+
// Question Detection Tests - Direct Questions
|
|
87
|
+
// =============================================================================
|
|
88
|
+
|
|
89
|
+
describe('detectQuestion - direct questions', () => {
|
|
90
|
+
it('should detect question mark at end of message', () => {
|
|
91
|
+
const msg = 'What would you like me to do?';
|
|
92
|
+
const result = detectQuestion(msg);
|
|
93
|
+
assert.strictEqual(result.detected, true);
|
|
94
|
+
assert.strictEqual(result.type, 'direct');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should detect question mark mid-message followed by new sentence', () => {
|
|
98
|
+
const msg = 'What do you need? I can help with several things.';
|
|
99
|
+
const result = detectQuestion(msg);
|
|
100
|
+
assert.strictEqual(result.detected, true);
|
|
101
|
+
assert.strictEqual(result.type, 'direct');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should detect question mark followed by newline and more content', () => {
|
|
105
|
+
const msg = 'What do you need?\n\nHere are your options:';
|
|
106
|
+
const result = detectQuestion(msg);
|
|
107
|
+
assert.strictEqual(result.detected, true);
|
|
108
|
+
assert.strictEqual(result.type, 'direct');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should detect multiple questions in message', () => {
|
|
112
|
+
const msg = 'Should I proceed? Or would you prefer a different approach?';
|
|
113
|
+
const result = detectQuestion(msg);
|
|
114
|
+
assert.strictEqual(result.detected, true);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// =============================================================================
|
|
119
|
+
// Question Detection Tests - Implicit Questions
|
|
120
|
+
// =============================================================================
|
|
121
|
+
|
|
122
|
+
describe('detectQuestion - implicit questions', () => {
|
|
123
|
+
it('should detect "would you like"', () => {
|
|
124
|
+
const msg = 'I can fix this. Would you like me to proceed.';
|
|
125
|
+
const result = detectQuestion(msg);
|
|
126
|
+
assert.strictEqual(result.detected, true);
|
|
127
|
+
assert.strictEqual(result.type, 'implicit');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should detect "should I"', () => {
|
|
131
|
+
const msg = 'The tests are passing. Should I commit the changes.';
|
|
132
|
+
const result = detectQuestion(msg);
|
|
133
|
+
assert.strictEqual(result.detected, true);
|
|
134
|
+
assert.strictEqual(result.type, 'implicit');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should detect "do you want"', () => {
|
|
138
|
+
const msg = 'Do you want me to run the full test suite.';
|
|
139
|
+
const result = detectQuestion(msg);
|
|
140
|
+
assert.strictEqual(result.detected, true);
|
|
141
|
+
assert.strictEqual(result.type, 'implicit');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should detect "let me know if"', () => {
|
|
145
|
+
const msg = 'I made the changes. Let me know if you need anything else.';
|
|
146
|
+
const result = detectQuestion(msg);
|
|
147
|
+
assert.strictEqual(result.detected, true);
|
|
148
|
+
assert.strictEqual(result.type, 'implicit');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should detect "what do you think"', () => {
|
|
152
|
+
const msg = 'Here is my proposed solution. What do you think.';
|
|
153
|
+
const result = detectQuestion(msg);
|
|
154
|
+
assert.strictEqual(result.detected, true);
|
|
155
|
+
assert.strictEqual(result.type, 'implicit');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should detect "your preference"', () => {
|
|
159
|
+
const msg = 'Both approaches work. Your preference on which to use.';
|
|
160
|
+
const result = detectQuestion(msg);
|
|
161
|
+
assert.strictEqual(result.detected, true);
|
|
162
|
+
assert.strictEqual(result.type, 'implicit');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should detect "ready to proceed"', () => {
|
|
166
|
+
const msg = 'Everything is set up. Ready to proceed with the deployment.';
|
|
167
|
+
const result = detectQuestion(msg);
|
|
168
|
+
assert.strictEqual(result.detected, true);
|
|
169
|
+
assert.strictEqual(result.type, 'implicit');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// =============================================================================
|
|
174
|
+
// Question Detection Tests - Choice Offerings
|
|
175
|
+
// =============================================================================
|
|
176
|
+
|
|
177
|
+
describe('detectQuestion - choice offerings', () => {
|
|
178
|
+
it('should detect "option A" style choices', () => {
|
|
179
|
+
const msg = 'We have two paths: Option A uses Redis, Option B uses memory.';
|
|
180
|
+
const result = detectQuestion(msg);
|
|
181
|
+
assert.strictEqual(result.detected, true);
|
|
182
|
+
assert.strictEqual(result.type, 'choices');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should detect "we could either"', () => {
|
|
186
|
+
const msg = 'We could either refactor now or defer to next sprint.';
|
|
187
|
+
const result = detectQuestion(msg);
|
|
188
|
+
assert.strictEqual(result.detected, true);
|
|
189
|
+
assert.strictEqual(result.type, 'choices');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should detect "alternatively"', () => {
|
|
193
|
+
const msg = 'I can add it inline. Alternatively, we create a helper function.';
|
|
194
|
+
const result = detectQuestion(msg);
|
|
195
|
+
assert.strictEqual(result.detected, true);
|
|
196
|
+
assert.strictEqual(result.type, 'choices');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should detect "or would you prefer"', () => {
|
|
200
|
+
const msg = 'I can use async/await, or would you prefer callbacks.';
|
|
201
|
+
const result = detectQuestion(msg);
|
|
202
|
+
assert.strictEqual(result.detected, true);
|
|
203
|
+
assert.strictEqual(result.type, 'choices');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should detect "choose between"', () => {
|
|
207
|
+
const msg = 'You can choose between TypeScript or JavaScript.';
|
|
208
|
+
const result = detectQuestion(msg);
|
|
209
|
+
assert.strictEqual(result.detected, true);
|
|
210
|
+
assert.strictEqual(result.type, 'choices');
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// =============================================================================
|
|
215
|
+
// Marker Detection Tests
|
|
216
|
+
// =============================================================================
|
|
217
|
+
|
|
218
|
+
describe('hasReflectorMarker', () => {
|
|
219
|
+
it('should detect QUESTION:yesno marker', () => {
|
|
220
|
+
const msg = `${MARKERS.yesno}\nShould I proceed?`;
|
|
221
|
+
const result = hasReflectorMarker(msg);
|
|
222
|
+
assert.strictEqual(result, true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should detect QUESTION:open marker', () => {
|
|
226
|
+
const msg = `${MARKERS.open}\nWhat approach would you prefer?`;
|
|
227
|
+
const result = hasReflectorMarker(msg);
|
|
228
|
+
assert.strictEqual(result, true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should detect CHOICES marker', () => {
|
|
232
|
+
const msg = `${MARKERS.choices}\nOption 1 or Option 2?`;
|
|
233
|
+
const result = hasReflectorMarker(msg);
|
|
234
|
+
assert.strictEqual(result, true);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should detect marker with extra whitespace', () => {
|
|
238
|
+
const msg = '<!-- CYCLIST:QUESTION:yesno -->\nShould I proceed?';
|
|
239
|
+
const result = hasReflectorMarker(msg);
|
|
240
|
+
assert.strictEqual(result, true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('should return false when no marker present', () => {
|
|
244
|
+
const msg = 'Should I proceed?';
|
|
245
|
+
const result = hasReflectorMarker(msg);
|
|
246
|
+
assert.strictEqual(result, false);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// =============================================================================
|
|
251
|
+
// False Positive Prevention Tests - Code Blocks
|
|
252
|
+
// =============================================================================
|
|
253
|
+
|
|
254
|
+
describe('detectQuestion - code block immunity', () => {
|
|
255
|
+
it('should NOT detect questions inside fenced code blocks', () => {
|
|
256
|
+
const msg = `Here is the code:
|
|
257
|
+
\`\`\`javascript
|
|
258
|
+
// What should this function return?
|
|
259
|
+
function test() { return true; }
|
|
260
|
+
\`\`\`
|
|
261
|
+
The implementation is complete.`;
|
|
262
|
+
const result = detectQuestion(msg);
|
|
263
|
+
assert.strictEqual(result.detected, false);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should NOT detect questions inside inline code', () => {
|
|
267
|
+
const msg = 'The function `shouldIProceed()` returns a boolean.';
|
|
268
|
+
const result = detectQuestion(msg);
|
|
269
|
+
assert.strictEqual(result.detected, false);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should detect questions OUTSIDE code blocks', () => {
|
|
273
|
+
const msg = `Here is the code:
|
|
274
|
+
\`\`\`javascript
|
|
275
|
+
function test() { return true; }
|
|
276
|
+
\`\`\`
|
|
277
|
+
What do you think?`;
|
|
278
|
+
const result = detectQuestion(msg);
|
|
279
|
+
assert.strictEqual(result.detected, true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('should handle multiple code blocks correctly', () => {
|
|
283
|
+
const msg = `First block:
|
|
284
|
+
\`\`\`
|
|
285
|
+
code here?
|
|
286
|
+
\`\`\`
|
|
287
|
+
Second block:
|
|
288
|
+
\`\`\`
|
|
289
|
+
more code?
|
|
290
|
+
\`\`\`
|
|
291
|
+
Done.`;
|
|
292
|
+
const result = detectQuestion(msg);
|
|
293
|
+
assert.strictEqual(result.detected, false);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// =============================================================================
|
|
298
|
+
// False Positive Prevention Tests - Rhetorical Questions
|
|
299
|
+
// =============================================================================
|
|
300
|
+
|
|
301
|
+
describe('detectQuestion - rhetorical question immunity', () => {
|
|
302
|
+
it('should NOT detect "the question was"', () => {
|
|
303
|
+
const msg = 'The question was whether to use async or sync. I chose async.';
|
|
304
|
+
const result = detectQuestion(msg);
|
|
305
|
+
assert.strictEqual(result.detected, false);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('should NOT detect "the question is"', () => {
|
|
309
|
+
const msg = 'The question is rhetorical. Here is the answer.';
|
|
310
|
+
const result = detectQuestion(msg);
|
|
311
|
+
assert.strictEqual(result.detected, false);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should NOT detect "asked whether"', () => {
|
|
315
|
+
const msg = 'You asked whether this was possible. Yes, it is.';
|
|
316
|
+
const result = detectQuestion(msg);
|
|
317
|
+
assert.strictEqual(result.detected, false);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('should NOT detect "wondering if"', () => {
|
|
321
|
+
const msg = 'I was wondering if this approach would work. It does.';
|
|
322
|
+
const result = detectQuestion(msg);
|
|
323
|
+
assert.strictEqual(result.detected, false);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// =============================================================================
|
|
328
|
+
// No Question Tests
|
|
329
|
+
// =============================================================================
|
|
330
|
+
|
|
331
|
+
describe('detectQuestion - no question present', () => {
|
|
332
|
+
it('should NOT detect statements without questions', () => {
|
|
333
|
+
const msg = 'I have completed the implementation. All tests pass.';
|
|
334
|
+
const result = detectQuestion(msg);
|
|
335
|
+
assert.strictEqual(result.detected, false);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('should NOT detect exclamations', () => {
|
|
339
|
+
const msg = 'Done! The feature is ready.';
|
|
340
|
+
const result = detectQuestion(msg);
|
|
341
|
+
assert.strictEqual(result.detected, false);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('should NOT detect periods only', () => {
|
|
345
|
+
const msg = 'Task complete. Moving on to the next item.';
|
|
346
|
+
const result = detectQuestion(msg);
|
|
347
|
+
assert.strictEqual(result.detected, false);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// =============================================================================
|
|
352
|
+
// Transcript Extraction Tests
|
|
353
|
+
// =============================================================================
|
|
354
|
+
|
|
355
|
+
describe('extractLastAssistantMessage', () => {
|
|
356
|
+
it('should extract last assistant message from JSONL', () => {
|
|
357
|
+
const transcript = [
|
|
358
|
+
{ role: 'user', content: 'Hello' },
|
|
359
|
+
{ role: 'assistant', content: 'Hi there!' },
|
|
360
|
+
{ role: 'user', content: 'Help me' },
|
|
361
|
+
{ role: 'assistant', content: 'What do you need?' },
|
|
362
|
+
];
|
|
363
|
+
const result = extractLastAssistantMessage(transcript);
|
|
364
|
+
assert.strictEqual(result, 'What do you need?');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('should handle content as array of text blocks', () => {
|
|
368
|
+
const transcript = [
|
|
369
|
+
{
|
|
370
|
+
role: 'assistant',
|
|
371
|
+
content: [
|
|
372
|
+
{ type: 'text', text: 'First part. ' },
|
|
373
|
+
{ type: 'text', text: 'What do you think?' },
|
|
374
|
+
],
|
|
375
|
+
},
|
|
376
|
+
];
|
|
377
|
+
const result = extractLastAssistantMessage(transcript);
|
|
378
|
+
assert.strictEqual(result, 'First part. What do you think?');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('should return empty string when no assistant message', () => {
|
|
382
|
+
const transcript = [{ role: 'user', content: 'Hello' }];
|
|
383
|
+
const result = extractLastAssistantMessage(transcript);
|
|
384
|
+
assert.strictEqual(result, '');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('should skip tool_use content blocks', () => {
|
|
388
|
+
const transcript = [
|
|
389
|
+
{
|
|
390
|
+
role: 'assistant',
|
|
391
|
+
content: [
|
|
392
|
+
{ type: 'text', text: 'Let me check.' },
|
|
393
|
+
{ type: 'tool_use', id: '123', name: 'Read' },
|
|
394
|
+
],
|
|
395
|
+
},
|
|
396
|
+
];
|
|
397
|
+
const result = extractLastAssistantMessage(transcript);
|
|
398
|
+
assert.strictEqual(result, 'Let me check.');
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// =============================================================================
|
|
403
|
+
// Integration Tests - Full Hook Logic
|
|
404
|
+
// =============================================================================
|
|
405
|
+
|
|
406
|
+
describe('checkQuestionReflector - integration', () => {
|
|
407
|
+
it('should return ok:true when no question detected', () => {
|
|
408
|
+
const input = {
|
|
409
|
+
transcript_path: '/tmp/test.jsonl',
|
|
410
|
+
stop_hook_active: false,
|
|
411
|
+
};
|
|
412
|
+
const config = CONFIG_MANUAL;
|
|
413
|
+
const lastMessage = 'Task complete. All done.';
|
|
414
|
+
const result = checkQuestionReflector(input, config, lastMessage);
|
|
415
|
+
assert.deepStrictEqual(result, { ok: true });
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('should return ok:true when question has marker', () => {
|
|
419
|
+
const input = {
|
|
420
|
+
transcript_path: '/tmp/test.jsonl',
|
|
421
|
+
stop_hook_active: false,
|
|
422
|
+
};
|
|
423
|
+
const config = CONFIG_MANUAL;
|
|
424
|
+
const lastMessage = `${MARKERS.yesno}\nShould I proceed?`;
|
|
425
|
+
const result = checkQuestionReflector(input, config, lastMessage);
|
|
426
|
+
assert.deepStrictEqual(result, { ok: true });
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('should return ok:true when in turbo mode (legacy)', () => {
|
|
430
|
+
const input = {
|
|
431
|
+
transcript_path: '/tmp/test.jsonl',
|
|
432
|
+
stop_hook_active: false,
|
|
433
|
+
};
|
|
434
|
+
const config = CONFIG_TURBO_LEGACY;
|
|
435
|
+
const lastMessage = 'What do you need?'; // No marker, but turbo mode
|
|
436
|
+
const result = checkQuestionReflector(input, config, lastMessage);
|
|
437
|
+
assert.deepStrictEqual(result, { ok: true });
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('should return ok:true when relay_mode is true', () => {
|
|
441
|
+
const input = {
|
|
442
|
+
transcript_path: '/tmp/test.jsonl',
|
|
443
|
+
stop_hook_active: false,
|
|
444
|
+
};
|
|
445
|
+
const config = CONFIG_RELAY_ON;
|
|
446
|
+
const lastMessage = 'What do you need?'; // No marker, but relay on
|
|
447
|
+
const result = checkQuestionReflector(input, config, lastMessage);
|
|
448
|
+
assert.deepStrictEqual(result, { ok: true });
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('should return ok:true when stop_hook_active is true', () => {
|
|
452
|
+
const input = {
|
|
453
|
+
transcript_path: '/tmp/test.jsonl',
|
|
454
|
+
stop_hook_active: true, // Prevent infinite loops
|
|
455
|
+
};
|
|
456
|
+
const config = CONFIG_MANUAL;
|
|
457
|
+
const lastMessage = 'What do you need?';
|
|
458
|
+
const result = checkQuestionReflector(input, config, lastMessage);
|
|
459
|
+
assert.deepStrictEqual(result, { ok: true });
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('should block when question detected without marker', () => {
|
|
463
|
+
const input = {
|
|
464
|
+
transcript_path: '/tmp/test.jsonl',
|
|
465
|
+
stop_hook_active: false,
|
|
466
|
+
};
|
|
467
|
+
const config = CONFIG_MANUAL;
|
|
468
|
+
const lastMessage = 'What do you need?';
|
|
469
|
+
const result = checkQuestionReflector(input, config, lastMessage);
|
|
470
|
+
assert.strictEqual(result.decision, 'block');
|
|
471
|
+
assert.ok(result.reason.includes('CYCLIST:QUESTION'));
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('should include appropriate marker hint for direct questions', () => {
|
|
475
|
+
const input = {
|
|
476
|
+
transcript_path: '/tmp/test.jsonl',
|
|
477
|
+
stop_hook_active: false,
|
|
478
|
+
};
|
|
479
|
+
const config = CONFIG_MANUAL;
|
|
480
|
+
const lastMessage = 'Should I proceed?';
|
|
481
|
+
const result = checkQuestionReflector(input, config, lastMessage);
|
|
482
|
+
assert.strictEqual(result.decision, 'block');
|
|
483
|
+
assert.ok(result.reason.includes('CYCLIST:QUESTION:yesno'));
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('should include appropriate marker hint for choices', () => {
|
|
487
|
+
const input = {
|
|
488
|
+
transcript_path: '/tmp/test.jsonl',
|
|
489
|
+
stop_hook_active: false,
|
|
490
|
+
};
|
|
491
|
+
const config = CONFIG_MANUAL;
|
|
492
|
+
const lastMessage = 'We could either use Option A or Option B.';
|
|
493
|
+
const result = checkQuestionReflector(input, config, lastMessage);
|
|
494
|
+
assert.strictEqual(result.decision, 'block');
|
|
495
|
+
assert.ok(result.reason.includes('CYCLIST:CHOICES'));
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// =============================================================================
|
|
500
|
+
// AskUserQuestion PreToolUse Hook Tests
|
|
501
|
+
// =============================================================================
|
|
502
|
+
|
|
503
|
+
describe('checkAskUserQuestion - PreToolUse hook', () => {
|
|
504
|
+
it('should return ok:true when relay_mode is true', () => {
|
|
505
|
+
const input = {
|
|
506
|
+
tool_name: 'AskUserQuestion',
|
|
507
|
+
tool_input: { questions: [{ question: 'Which option?' }] },
|
|
508
|
+
};
|
|
509
|
+
const config = CONFIG_RELAY_ON;
|
|
510
|
+
const result = checkAskUserQuestion(input, config);
|
|
511
|
+
assert.deepStrictEqual(result, { ok: true });
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('should return ok:true when in turbo mode (legacy)', () => {
|
|
515
|
+
const input = {
|
|
516
|
+
tool_name: 'AskUserQuestion',
|
|
517
|
+
tool_input: { questions: [{ question: 'Which option?' }] },
|
|
518
|
+
};
|
|
519
|
+
const config = CONFIG_TURBO_LEGACY;
|
|
520
|
+
const result = checkAskUserQuestion(input, config);
|
|
521
|
+
assert.deepStrictEqual(result, { ok: true });
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('should block AskUserQuestion without prior marker in transcript', () => {
|
|
525
|
+
const input = {
|
|
526
|
+
tool_name: 'AskUserQuestion',
|
|
527
|
+
tool_input: { questions: [{ question: 'Which option?' }] },
|
|
528
|
+
};
|
|
529
|
+
const config = CONFIG_MANUAL;
|
|
530
|
+
const transcriptWithoutMarker = 'Here are some choices.';
|
|
531
|
+
const result = checkAskUserQuestion(input, config, transcriptWithoutMarker);
|
|
532
|
+
assert.strictEqual(result.decision, 'block');
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('should allow AskUserQuestion when marker present in recent output', () => {
|
|
536
|
+
const input = {
|
|
537
|
+
tool_name: 'AskUserQuestion',
|
|
538
|
+
tool_input: { questions: [{ question: 'Which option?' }] },
|
|
539
|
+
};
|
|
540
|
+
const config = CONFIG_MANUAL;
|
|
541
|
+
const transcriptWithMarker = `${MARKERS.choices}\nHere are some choices.`;
|
|
542
|
+
const result = checkAskUserQuestion(input, config, transcriptWithMarker);
|
|
543
|
+
assert.deepStrictEqual(result, { ok: true });
|
|
544
|
+
});
|
|
545
|
+
});
|
|
@@ -223,6 +223,7 @@ if $DRY_RUN; then
|
|
|
223
223
|
log_dry "git tag -a $TAG_NAME -m 'Release $NEW_VERSION'"
|
|
224
224
|
log_dry "git push origin develop main --tags"
|
|
225
225
|
log_dry "gh release create $TAG_NAME --title 'Release $NEW_VERSION' --notes-from-tag --verify-tag"
|
|
226
|
+
log_dry "source .env && npm publish --access public"
|
|
226
227
|
log_dry "git checkout develop"
|
|
227
228
|
else
|
|
228
229
|
log_info "Merging develop to main..."
|
|
@@ -261,7 +262,16 @@ else
|
|
|
261
262
|
log_warn "Create release manually at: https://github.com/1898andCo/pennyfarthing/releases/new"
|
|
262
263
|
fi
|
|
263
264
|
|
|
264
|
-
# Step 8:
|
|
265
|
+
# Step 8: Publish to npm
|
|
266
|
+
log_info "Publishing to npm..."
|
|
267
|
+
if [[ -f "$PROJECT_ROOT/.env" ]]; then
|
|
268
|
+
source "$PROJECT_ROOT/.env"
|
|
269
|
+
npm config set //registry.npmjs.org/:_authToken "$NPM_TOKEN"
|
|
270
|
+
fi
|
|
271
|
+
(cd "$PROJECT_ROOT" && npm publish --access public)
|
|
272
|
+
log_info "Published @pennyfarthing/core@$NEW_VERSION to npm"
|
|
273
|
+
|
|
274
|
+
# Step 9: Return to develop
|
|
265
275
|
log_info "Returning to develop..."
|
|
266
276
|
git -C "$PROJECT_ROOT" checkout develop
|
|
267
277
|
fi
|
|
@@ -273,12 +283,14 @@ if $DRY_RUN; then
|
|
|
273
283
|
echo " Would release version: $NEW_VERSION"
|
|
274
284
|
echo " Would create tag: $TAG_NAME"
|
|
275
285
|
echo " Would create GitHub release: $TAG_NAME"
|
|
286
|
+
echo " Would publish: @pennyfarthing/core@$NEW_VERSION"
|
|
276
287
|
else
|
|
277
288
|
log_info "Deploy complete!"
|
|
278
289
|
echo ""
|
|
279
290
|
echo " Version: $NEW_VERSION"
|
|
280
291
|
echo " Tag: $TAG_NAME"
|
|
281
292
|
echo " GitHub release: https://github.com/1898andCo/pennyfarthing/releases/tag/$TAG_NAME"
|
|
293
|
+
echo " npm: @pennyfarthing/core@$NEW_VERSION"
|
|
282
294
|
echo " Branches pushed: develop, main"
|
|
283
295
|
fi
|
|
284
296
|
echo ""
|