@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pennyfarthing/core",
3
- "version": "7.6.0",
3
+ "version": "7.6.1",
4
4
  "description": "Claude Code agent framework with TDD workflow and persona system",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 yes/no dialog |
162
- | `CHOICES` | `1,2,3` | Shows choice buttons |
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 and 'permission_mode' in config['workflow']:
72
- permission_mode = config['workflow']['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 and 'permission_mode' in settings['workflow']:
86
- permission_mode = settings['workflow']['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. permission_mode is 'turbo' (auto-handoff enabled)
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
- use_tirepump = permission_mode == 'turbo' and usable_pct > tirepump_threshold
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: Return to develop
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 ""