@pennyfarthing/core 7.6.0 → 7.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +109 -201
  2. package/package.json +1 -1
  3. package/packages/core/dist/cli/commands/doctor.d.ts.map +1 -1
  4. package/packages/core/dist/cli/commands/doctor.js +91 -0
  5. package/packages/core/dist/cli/commands/doctor.js.map +1 -1
  6. package/packages/core/dist/cli/commands/init.js +31 -0
  7. package/packages/core/dist/cli/commands/init.js.map +1 -1
  8. package/packages/core/dist/cli/commands/update.js +31 -0
  9. package/packages/core/dist/cli/commands/update.js.map +1 -1
  10. package/pennyfarthing-dist/agents/architect.md +48 -53
  11. package/pennyfarthing-dist/agents/dev.md +74 -164
  12. package/pennyfarthing-dist/agents/devops.md +44 -39
  13. package/pennyfarthing-dist/agents/handoff.md +46 -23
  14. package/pennyfarthing-dist/agents/orchestrator.md +84 -255
  15. package/pennyfarthing-dist/agents/pm.md +40 -50
  16. package/pennyfarthing-dist/agents/reviewer-preflight.md +58 -26
  17. package/pennyfarthing-dist/agents/reviewer.md +107 -298
  18. package/pennyfarthing-dist/agents/sm-file-summary.md +51 -30
  19. package/pennyfarthing-dist/agents/sm-finish.md +59 -38
  20. package/pennyfarthing-dist/agents/sm-handoff.md +40 -33
  21. package/pennyfarthing-dist/agents/sm-setup.md +89 -47
  22. package/pennyfarthing-dist/agents/sm.md +171 -558
  23. package/pennyfarthing-dist/agents/tea.md +77 -146
  24. package/pennyfarthing-dist/agents/tech-writer.md +43 -24
  25. package/pennyfarthing-dist/agents/testing-runner.md +73 -30
  26. package/pennyfarthing-dist/agents/ux-designer.md +39 -25
  27. package/pennyfarthing-dist/agents/workflow-status-check.md +34 -16
  28. package/pennyfarthing-dist/commands/benchmark.md +19 -1
  29. package/pennyfarthing-dist/commands/continue-session.md +1 -1
  30. package/pennyfarthing-dist/commands/solo.md +5 -0
  31. package/pennyfarthing-dist/commands/theme-maker.md +5 -5
  32. package/pennyfarthing-dist/commands/work.md +1 -1
  33. package/pennyfarthing-dist/guides/XML-TAGS.md +179 -0
  34. package/pennyfarthing-dist/guides/agent-behavior.md +37 -2
  35. package/pennyfarthing-dist/guides/agent-tag-taxonomy.md +432 -0
  36. package/pennyfarthing-dist/guides/patterns/approval-gates-pattern.md +27 -7
  37. package/pennyfarthing-dist/guides/scale-levels.md +114 -0
  38. package/pennyfarthing-dist/personas/themes/gilligans-island.yaml +2 -2
  39. package/pennyfarthing-dist/personas/themes/star-trek-tos.yaml +1 -1
  40. package/pennyfarthing-dist/scripts/core/agent-session.sh +13 -7
  41. package/pennyfarthing-dist/scripts/core/check-context.sh +25 -8
  42. package/pennyfarthing-dist/scripts/core/prime.sh +57 -32
  43. package/pennyfarthing-dist/scripts/git/create-feature-branches.sh +45 -4
  44. package/pennyfarthing-dist/scripts/git/git-status-all.sh +32 -7
  45. package/pennyfarthing-dist/scripts/hooks/bell-mode-hook.sh +30 -11
  46. package/pennyfarthing-dist/scripts/hooks/pre-commit.sh +80 -23
  47. package/pennyfarthing-dist/scripts/hooks/question-reflector-check.mjs +393 -0
  48. package/pennyfarthing-dist/scripts/hooks/question-reflector-check.sh +20 -0
  49. package/pennyfarthing-dist/scripts/hooks/question_reflector_check.py +402 -0
  50. package/pennyfarthing-dist/scripts/hooks/session-stop.sh +7 -0
  51. package/pennyfarthing-dist/scripts/hooks/tests/question-reflector.test.mjs +545 -0
  52. package/pennyfarthing-dist/scripts/hooks/welcome-hook.sh +94 -0
  53. package/pennyfarthing-dist/scripts/jira/jira-claim-story.sh +10 -152
  54. package/pennyfarthing-dist/scripts/jira/jira-sync-story.sh +14 -4
  55. package/pennyfarthing-dist/scripts/jira/jira-sync.sh +12 -4
  56. package/pennyfarthing-dist/scripts/jira/sync-epic-jira.sh +11 -99
  57. package/pennyfarthing-dist/scripts/lib/common.sh +55 -0
  58. package/pennyfarthing-dist/scripts/maintenance/sidecar-health.sh +97 -0
  59. package/pennyfarthing-dist/scripts/misc/deploy.sh +13 -1
  60. package/pennyfarthing-dist/scripts/misc/statusline.sh +27 -22
  61. package/pennyfarthing-dist/scripts/story/create-story.sh +14 -154
  62. package/pennyfarthing-dist/scripts/story/size-story.sh +12 -192
  63. package/pennyfarthing-dist/scripts/story/story-template.sh +12 -156
  64. package/pennyfarthing-dist/scripts/test/ground-truth-judge.py +24 -93
  65. package/pennyfarthing-dist/scripts/test/swebench-judge.py +33 -59
  66. package/pennyfarthing-dist/scripts/validation/validate-agent-schema.sh +575 -0
  67. package/pennyfarthing-dist/scripts/workflow/check.py +502 -0
  68. package/pennyfarthing-dist/skills/skill-registry.yaml +52 -16
  69. package/pennyfarthing-dist/skills/sprint/skill.md +1 -1
  70. package/pennyfarthing-dist/templates/settings.local.json.template +11 -0
@@ -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
+ });
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env bash
2
+ # Welcome Hook: Display a friendly welcome message on session start
3
+ #
4
+ # For CLI: Displays ASCII art of a penny-farthing bicycle
5
+ # For Cyclist: Sends WebSocket message to display logo and welcome
6
+ #
7
+ # Called by Claude Code SessionStart hook
8
+
9
+ set -euo pipefail
10
+
11
+ # Read and discard stdin (required by hook protocol)
12
+ cat > /dev/null
13
+
14
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
15
+ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(cd "$SCRIPT_DIR/../../.." && pwd)}"
16
+
17
+ # Once-per-session guard: only show welcome on first invocation
18
+ # Use session ID from environment or generate a unique one
19
+ SESSION_ID="${CLAUDE_SESSION_ID:-$$}"
20
+ WELCOME_LOCK="$PROJECT_ROOT/.session/.welcome-shown-$SESSION_ID"
21
+
22
+ # Ensure .session directory exists
23
+ mkdir -p "$PROJECT_ROOT/.session"
24
+
25
+ # Check if welcome was already shown for this session
26
+ if [[ -f "$WELCOME_LOCK" ]]; then
27
+ exit 0
28
+ fi
29
+
30
+ # Mark welcome as shown for this session
31
+ touch "$WELCOME_LOCK"
32
+
33
+ # Check if running in Cyclist (port file exists)
34
+ PORT_FILE="$PROJECT_ROOT/.cyclist-port"
35
+ IN_CYCLIST=false
36
+ if [[ -f "$PORT_FILE" ]]; then
37
+ CYCLIST_PORT=$(cat "$PORT_FILE" 2>/dev/null)
38
+ if [[ "$CYCLIST_PORT" =~ ^[0-9]+$ ]]; then
39
+ IN_CYCLIST=true
40
+ fi
41
+ fi
42
+
43
+ # Get project name from package.json or directory name
44
+ PROJECT_NAME=""
45
+ if [[ -f "$PROJECT_ROOT/package.json" ]]; then
46
+ PROJECT_NAME=$(jq -r '.name // empty' "$PROJECT_ROOT/package.json" 2>/dev/null || echo "")
47
+ fi
48
+ if [[ -z "$PROJECT_NAME" ]]; then
49
+ PROJECT_NAME=$(basename "$PROJECT_ROOT")
50
+ fi
51
+
52
+ # Get current theme from config
53
+ THEME=""
54
+ if [[ -f "$PROJECT_ROOT/.pennyfarthing/config.local.yaml" ]]; then
55
+ THEME=$(grep -E '^theme:' "$PROJECT_ROOT/.pennyfarthing/config.local.yaml" 2>/dev/null | sed 's/theme:[[:space:]]*//' | tr -d '"' || echo "")
56
+ fi
57
+
58
+ if [[ "$IN_CYCLIST" == "true" ]]; then
59
+ # Send welcome message via WebSocket API for Cyclist to display
60
+ # Cyclist will show the pennyfarthing logo image and welcome text
61
+ curl -s -X POST "http://localhost:$CYCLIST_PORT/api/welcome" \
62
+ -H "Content-Type: application/json" \
63
+ -d "{\"project\": \"$PROJECT_NAME\", \"theme\": \"$THEME\"}" \
64
+ >/dev/null 2>&1 || true
65
+ else
66
+ # CLI mode: Display ASCII art welcome
67
+ cat << 'EOF'
68
+
69
+ ___
70
+ / \
71
+ | | Welcome to
72
+ | | ╔═══════════════════════════════════╗
73
+ \___/ ║ ╔═╗╔═╗╔╗╔╔╗╔╦═╗╔═╗╔═╗╔═╗╦╔═╗ ║
74
+ ║ ║ ╠═╝║╣ ║║║║║║ ╠╣ ╠═╣╠╦╝ ║ ╠═╣ ║
75
+ ║ ║ ╩ ╚═╝╝╚╝╝╚╝╩ ╩ ╩╩╚═ ╩ ╩ ╩ ║
76
+ ╔═╩═╗ ╚═══════════════════════════════════╝
77
+ / \
78
+ │ O │ Agent-powered development with style
79
+ \ /
80
+ ╚═══╝
81
+
82
+ EOF
83
+
84
+ # Add project-specific info
85
+ if [[ -n "$PROJECT_NAME" ]]; then
86
+ echo " Project: $PROJECT_NAME"
87
+ fi
88
+ if [[ -n "$THEME" ]]; then
89
+ echo " Theme: $THEME"
90
+ fi
91
+ echo ""
92
+ fi
93
+
94
+ exit 0