commandmate 0.2.3 → 0.2.4

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 (61) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +25 -25
  3. package/.next/app-path-routes-manifest.json +1 -1
  4. package/.next/build-manifest.json +7 -7
  5. package/.next/cache/.tsbuildinfo +1 -1
  6. package/.next/cache/config.json +3 -3
  7. package/.next/cache/fetch-cache/799a63cbfa61e2ab38626c05fe43500464c7bbd38341bdde69f5ec4b25acff68 +1 -0
  8. package/.next/cache/webpack/client-production/0.pack +0 -0
  9. package/.next/cache/webpack/client-production/1.pack +0 -0
  10. package/.next/cache/webpack/client-production/2.pack +0 -0
  11. package/.next/cache/webpack/client-production/index.pack +0 -0
  12. package/.next/cache/webpack/client-production/index.pack.old +0 -0
  13. package/.next/cache/webpack/edge-server-production/index.pack +0 -0
  14. package/.next/cache/webpack/server-production/0.pack +0 -0
  15. package/.next/cache/webpack/server-production/index.pack +0 -0
  16. package/.next/next-minimal-server.js.nft.json +1 -1
  17. package/.next/next-server.js.nft.json +1 -1
  18. package/.next/prerender-manifest.json +1 -1
  19. package/.next/required-server-files.json +1 -1
  20. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  21. package/.next/server/app/api/app/update-check/route.js +1 -0
  22. package/.next/server/app/api/app/update-check/route.js.nft.json +1 -0
  23. package/.next/server/app/api/app/update-check.body +1 -0
  24. package/.next/server/app/api/app/update-check.meta +1 -0
  25. package/.next/server/app/api/repositories/restore/route.js +1 -1
  26. package/.next/server/app/api/repositories/scan/route.js +1 -1
  27. package/.next/server/app/api/repositories/sync/route.js +1 -1
  28. package/.next/server/app/api/worktrees/[id]/logs/[filename]/route.js +1 -1
  29. package/.next/server/app/api/worktrees/[id]/logs/route.js +2 -2
  30. package/.next/server/app/api/worktrees/[id]/search/route.js +1 -1
  31. package/.next/server/app/page_client-reference-manifest.js +1 -1
  32. package/.next/server/app/proxy/[...path]/route.js +1 -1
  33. package/.next/server/app/worktrees/[id]/files/[...path]/page_client-reference-manifest.js +1 -1
  34. package/.next/server/app/worktrees/[id]/page.js +3 -3
  35. package/.next/server/app/worktrees/[id]/page_client-reference-manifest.js +1 -1
  36. package/.next/server/app/worktrees/[id]/terminal/page_client-reference-manifest.js +1 -1
  37. package/.next/server/app-paths-manifest.json +10 -9
  38. package/.next/server/chunks/2683.js +1 -1
  39. package/.next/server/chunks/5488.js +1 -1
  40. package/.next/server/chunks/5823.js +1 -1
  41. package/.next/server/chunks/7425.js +3 -3
  42. package/.next/server/chunks/7536.js +1 -1
  43. package/.next/server/chunks/9367.js +2 -2
  44. package/.next/server/functions-config-manifest.json +1 -1
  45. package/.next/server/middleware-build-manifest.js +1 -1
  46. package/.next/server/pages/500.html +1 -1
  47. package/.next/server/server-reference-manifest.json +1 -1
  48. package/.next/static/chunks/{816-bb41b20a51ae924a.js → 816-af44cb865b0c980e.js} +1 -1
  49. package/.next/static/chunks/app/page-43b5de1a0a788b1f.js +1 -0
  50. package/.next/static/chunks/app/worktrees/[id]/{page-9d77c6f755d08086.js → page-9632761937a4d1ad.js} +1 -1
  51. package/.next/static/chunks/{main-b6d727aa9248d4f2.js → main-f00f82f1cf18dd99.js} +1 -1
  52. package/.next/static/chunks/{webpack-e6531fcf859d9451.js → webpack-af8567a485ade35a.js} +1 -1
  53. package/.next/static/css/{4eca30cb81bc52b4.css → 6a92c8ad3c94d15a.css} +1 -1
  54. package/.next/trace +5 -5
  55. package/.next/types/app/api/app/update-check/route.ts +343 -0
  56. package/dist/server/src/lib/prompt-detector.js +207 -67
  57. package/package.json +1 -1
  58. package/.next/static/chunks/app/page-792c0577dc44e5e5.js +0 -1
  59. /package/.next/static/{rppRTm2sRWa4sZE7ili8A → b3UR0y5mw3Ubf_vI5JjIN}/_buildManifest.js +0 -0
  60. /package/.next/static/{rppRTm2sRWa4sZE7ili8A → b3UR0y5mw3Ubf_vI5JjIN}/_ssgManifest.js +0 -0
  61. /package/.next/static/chunks/{4733-db0112b08802aaa7.js → 4733-50bdfc169adb4881.js} +0 -0
@@ -0,0 +1,343 @@
1
+ // File: /home/runner/work/CommandMate/CommandMate/src/app/api/app/update-check/route.ts
2
+ import * as entry from '../../../../../../src/app/api/app/update-check/route.js'
3
+ import type { NextRequest } from 'next/server.js'
4
+
5
+ type TEntry = typeof import('../../../../../../src/app/api/app/update-check/route.js')
6
+
7
+ // Check that the entry is a valid entry
8
+ checkFields<Diff<{
9
+ GET?: Function
10
+ HEAD?: Function
11
+ OPTIONS?: Function
12
+ POST?: Function
13
+ PUT?: Function
14
+ DELETE?: Function
15
+ PATCH?: Function
16
+ config?: {}
17
+ generateStaticParams?: Function
18
+ revalidate?: RevalidateRange<TEntry> | false
19
+ dynamic?: 'auto' | 'force-dynamic' | 'error' | 'force-static'
20
+ dynamicParams?: boolean
21
+ fetchCache?: 'auto' | 'force-no-store' | 'only-no-store' | 'default-no-store' | 'default-cache' | 'only-cache' | 'force-cache'
22
+ preferredRegion?: 'auto' | 'global' | 'home' | string | string[]
23
+ runtime?: 'nodejs' | 'experimental-edge' | 'edge'
24
+ maxDuration?: number
25
+
26
+ }, TEntry, ''>>()
27
+
28
+ // Check the prop type of the entry function
29
+ if ('GET' in entry) {
30
+ checkFields<
31
+ Diff<
32
+ ParamCheck<Request | NextRequest>,
33
+ {
34
+ __tag__: 'GET'
35
+ __param_position__: 'first'
36
+ __param_type__: FirstArg<MaybeField<TEntry, 'GET'>>
37
+ },
38
+ 'GET'
39
+ >
40
+ >()
41
+ checkFields<
42
+ Diff<
43
+ ParamCheck<PageParams>,
44
+ {
45
+ __tag__: 'GET'
46
+ __param_position__: 'second'
47
+ __param_type__: SecondArg<MaybeField<TEntry, 'GET'>>
48
+ },
49
+ 'GET'
50
+ >
51
+ >()
52
+
53
+ checkFields<
54
+ Diff<
55
+ {
56
+ __tag__: 'GET',
57
+ __return_type__: Response | void | never | Promise<Response | void | never>
58
+ },
59
+ {
60
+ __tag__: 'GET',
61
+ __return_type__: ReturnType<MaybeField<TEntry, 'GET'>>
62
+ },
63
+ 'GET'
64
+ >
65
+ >()
66
+ }
67
+ // Check the prop type of the entry function
68
+ if ('HEAD' in entry) {
69
+ checkFields<
70
+ Diff<
71
+ ParamCheck<Request | NextRequest>,
72
+ {
73
+ __tag__: 'HEAD'
74
+ __param_position__: 'first'
75
+ __param_type__: FirstArg<MaybeField<TEntry, 'HEAD'>>
76
+ },
77
+ 'HEAD'
78
+ >
79
+ >()
80
+ checkFields<
81
+ Diff<
82
+ ParamCheck<PageParams>,
83
+ {
84
+ __tag__: 'HEAD'
85
+ __param_position__: 'second'
86
+ __param_type__: SecondArg<MaybeField<TEntry, 'HEAD'>>
87
+ },
88
+ 'HEAD'
89
+ >
90
+ >()
91
+
92
+ checkFields<
93
+ Diff<
94
+ {
95
+ __tag__: 'HEAD',
96
+ __return_type__: Response | void | never | Promise<Response | void | never>
97
+ },
98
+ {
99
+ __tag__: 'HEAD',
100
+ __return_type__: ReturnType<MaybeField<TEntry, 'HEAD'>>
101
+ },
102
+ 'HEAD'
103
+ >
104
+ >()
105
+ }
106
+ // Check the prop type of the entry function
107
+ if ('OPTIONS' in entry) {
108
+ checkFields<
109
+ Diff<
110
+ ParamCheck<Request | NextRequest>,
111
+ {
112
+ __tag__: 'OPTIONS'
113
+ __param_position__: 'first'
114
+ __param_type__: FirstArg<MaybeField<TEntry, 'OPTIONS'>>
115
+ },
116
+ 'OPTIONS'
117
+ >
118
+ >()
119
+ checkFields<
120
+ Diff<
121
+ ParamCheck<PageParams>,
122
+ {
123
+ __tag__: 'OPTIONS'
124
+ __param_position__: 'second'
125
+ __param_type__: SecondArg<MaybeField<TEntry, 'OPTIONS'>>
126
+ },
127
+ 'OPTIONS'
128
+ >
129
+ >()
130
+
131
+ checkFields<
132
+ Diff<
133
+ {
134
+ __tag__: 'OPTIONS',
135
+ __return_type__: Response | void | never | Promise<Response | void | never>
136
+ },
137
+ {
138
+ __tag__: 'OPTIONS',
139
+ __return_type__: ReturnType<MaybeField<TEntry, 'OPTIONS'>>
140
+ },
141
+ 'OPTIONS'
142
+ >
143
+ >()
144
+ }
145
+ // Check the prop type of the entry function
146
+ if ('POST' in entry) {
147
+ checkFields<
148
+ Diff<
149
+ ParamCheck<Request | NextRequest>,
150
+ {
151
+ __tag__: 'POST'
152
+ __param_position__: 'first'
153
+ __param_type__: FirstArg<MaybeField<TEntry, 'POST'>>
154
+ },
155
+ 'POST'
156
+ >
157
+ >()
158
+ checkFields<
159
+ Diff<
160
+ ParamCheck<PageParams>,
161
+ {
162
+ __tag__: 'POST'
163
+ __param_position__: 'second'
164
+ __param_type__: SecondArg<MaybeField<TEntry, 'POST'>>
165
+ },
166
+ 'POST'
167
+ >
168
+ >()
169
+
170
+ checkFields<
171
+ Diff<
172
+ {
173
+ __tag__: 'POST',
174
+ __return_type__: Response | void | never | Promise<Response | void | never>
175
+ },
176
+ {
177
+ __tag__: 'POST',
178
+ __return_type__: ReturnType<MaybeField<TEntry, 'POST'>>
179
+ },
180
+ 'POST'
181
+ >
182
+ >()
183
+ }
184
+ // Check the prop type of the entry function
185
+ if ('PUT' in entry) {
186
+ checkFields<
187
+ Diff<
188
+ ParamCheck<Request | NextRequest>,
189
+ {
190
+ __tag__: 'PUT'
191
+ __param_position__: 'first'
192
+ __param_type__: FirstArg<MaybeField<TEntry, 'PUT'>>
193
+ },
194
+ 'PUT'
195
+ >
196
+ >()
197
+ checkFields<
198
+ Diff<
199
+ ParamCheck<PageParams>,
200
+ {
201
+ __tag__: 'PUT'
202
+ __param_position__: 'second'
203
+ __param_type__: SecondArg<MaybeField<TEntry, 'PUT'>>
204
+ },
205
+ 'PUT'
206
+ >
207
+ >()
208
+
209
+ checkFields<
210
+ Diff<
211
+ {
212
+ __tag__: 'PUT',
213
+ __return_type__: Response | void | never | Promise<Response | void | never>
214
+ },
215
+ {
216
+ __tag__: 'PUT',
217
+ __return_type__: ReturnType<MaybeField<TEntry, 'PUT'>>
218
+ },
219
+ 'PUT'
220
+ >
221
+ >()
222
+ }
223
+ // Check the prop type of the entry function
224
+ if ('DELETE' in entry) {
225
+ checkFields<
226
+ Diff<
227
+ ParamCheck<Request | NextRequest>,
228
+ {
229
+ __tag__: 'DELETE'
230
+ __param_position__: 'first'
231
+ __param_type__: FirstArg<MaybeField<TEntry, 'DELETE'>>
232
+ },
233
+ 'DELETE'
234
+ >
235
+ >()
236
+ checkFields<
237
+ Diff<
238
+ ParamCheck<PageParams>,
239
+ {
240
+ __tag__: 'DELETE'
241
+ __param_position__: 'second'
242
+ __param_type__: SecondArg<MaybeField<TEntry, 'DELETE'>>
243
+ },
244
+ 'DELETE'
245
+ >
246
+ >()
247
+
248
+ checkFields<
249
+ Diff<
250
+ {
251
+ __tag__: 'DELETE',
252
+ __return_type__: Response | void | never | Promise<Response | void | never>
253
+ },
254
+ {
255
+ __tag__: 'DELETE',
256
+ __return_type__: ReturnType<MaybeField<TEntry, 'DELETE'>>
257
+ },
258
+ 'DELETE'
259
+ >
260
+ >()
261
+ }
262
+ // Check the prop type of the entry function
263
+ if ('PATCH' in entry) {
264
+ checkFields<
265
+ Diff<
266
+ ParamCheck<Request | NextRequest>,
267
+ {
268
+ __tag__: 'PATCH'
269
+ __param_position__: 'first'
270
+ __param_type__: FirstArg<MaybeField<TEntry, 'PATCH'>>
271
+ },
272
+ 'PATCH'
273
+ >
274
+ >()
275
+ checkFields<
276
+ Diff<
277
+ ParamCheck<PageParams>,
278
+ {
279
+ __tag__: 'PATCH'
280
+ __param_position__: 'second'
281
+ __param_type__: SecondArg<MaybeField<TEntry, 'PATCH'>>
282
+ },
283
+ 'PATCH'
284
+ >
285
+ >()
286
+
287
+ checkFields<
288
+ Diff<
289
+ {
290
+ __tag__: 'PATCH',
291
+ __return_type__: Response | void | never | Promise<Response | void | never>
292
+ },
293
+ {
294
+ __tag__: 'PATCH',
295
+ __return_type__: ReturnType<MaybeField<TEntry, 'PATCH'>>
296
+ },
297
+ 'PATCH'
298
+ >
299
+ >()
300
+ }
301
+
302
+ // Check the arguments and return type of the generateStaticParams function
303
+ if ('generateStaticParams' in entry) {
304
+ checkFields<Diff<{ params: PageParams }, FirstArg<MaybeField<TEntry, 'generateStaticParams'>>, 'generateStaticParams'>>()
305
+ checkFields<Diff<{ __tag__: 'generateStaticParams', __return_type__: any[] | Promise<any[]> }, { __tag__: 'generateStaticParams', __return_type__: ReturnType<MaybeField<TEntry, 'generateStaticParams'>> }>>()
306
+ }
307
+
308
+ type PageParams = any
309
+ export interface PageProps {
310
+ params?: any
311
+ searchParams?: any
312
+ }
313
+ export interface LayoutProps {
314
+ children?: React.ReactNode
315
+
316
+ params?: any
317
+ }
318
+
319
+ // =============
320
+ // Utility types
321
+ type RevalidateRange<T> = T extends { revalidate: any } ? NonNegative<T['revalidate']> : never
322
+
323
+ // If T is unknown or any, it will be an empty {} type. Otherwise, it will be the same as Omit<T, keyof Base>.
324
+ type OmitWithTag<T, K extends keyof any, _M> = Omit<T, K>
325
+ type Diff<Base, T extends Base, Message extends string = ''> = 0 extends (1 & T) ? {} : OmitWithTag<T, keyof Base, Message>
326
+
327
+ type FirstArg<T extends Function> = T extends (...args: [infer T, any]) => any ? unknown extends T ? any : T : never
328
+ type SecondArg<T extends Function> = T extends (...args: [any, infer T]) => any ? unknown extends T ? any : T : never
329
+ type MaybeField<T, K extends string> = T extends { [k in K]: infer G } ? G extends Function ? G : never : never
330
+
331
+ type ParamCheck<T> = {
332
+ __tag__: string
333
+ __param_position__: string
334
+ __param_type__: T
335
+ }
336
+
337
+ function checkFields<_ extends { [k in keyof any]: never }>() {}
338
+
339
+ // https://github.com/sindresorhus/type-fest
340
+ type Numeric = number | bigint
341
+ type Zero = 0 | 0n
342
+ type Negative<T extends Numeric> = T extends Zero ? never : `${T}` extends `-${string}` ? T : never
343
+ type NonNegative<T extends Numeric> = T extends Zero ? T : Negative<T> extends never ? T : '__invalid_negative_number__'
@@ -184,6 +184,28 @@ const NORMAL_OPTION_PATTERN = /^\s*(\d+)\.\s*(.+)$/;
184
184
  * Anchored at both ends -- ReDoS safe (S4-001).
185
185
  */
186
186
  const SEPARATOR_LINE_PATTERN = /^[-─]+$/;
187
+ /**
188
+ * Maximum number of lines to scan upward from questionEndIndex
189
+ * when the questionEndIndex line itself is not a question-like line.
190
+ *
191
+ * Design rationale (IC-256-001):
192
+ * - model selection prompts have 1-2 lines between "Select model" and first option
193
+ * - multi-line question wrapping typically produces 2-3 continuation lines
194
+ * - value of 3 covers these cases while minimizing False Positive surface
195
+ *
196
+ * [SF-002] Change guidelines:
197
+ * - Increase this value ONLY if real-world prompts are discovered where
198
+ * the question line is more than 3 lines above questionEndIndex
199
+ * - Before increasing, verify that the new value does not cause
200
+ * T11h-T11m False Positive tests to fail
201
+ * - Consider that larger values increase the False Positive surface area
202
+ * - If increasing beyond 5, consider whether the detection approach
203
+ * itself needs to be redesigned (e.g., pattern-based instead of scan-based)
204
+ * - Document the specific prompt pattern that necessitated the change
205
+ *
206
+ * @see Issue #256: multiple_choice prompt detection improvement
207
+ */
208
+ const QUESTION_SCAN_RANGE = 3;
187
209
  /**
188
210
  * Creates a "no prompt detected" result.
189
211
  * Centralizes the repeated pattern of returning isPrompt: false with trimmed content.
@@ -244,17 +266,75 @@ function isQuestionLikeLine(line) {
244
266
  // Empty lines are not questions
245
267
  if (line.length === 0)
246
268
  return false;
247
- // Pattern 1: Lines ending with question mark (English or full-width Japanese)
269
+ // Pattern 1: Lines containing question mark anywhere (English '?' or full-width U+FF1F).
270
+ // This covers both:
271
+ // - Lines ending with '?' (standard question format)
272
+ // - Lines with '?' mid-line (Issue #256: multi-line question wrapping where '?'
273
+ // appears mid-line due to terminal width causing the question text to wrap)
274
+ //
248
275
  // Full-width question mark (U+FF1F) support is a defensive measure: Claude Code/CLI
249
276
  // displays questions in English, but this covers future multi-language support
250
277
  // and third-party tool integration.
251
- if (line.endsWith('?') || line.endsWith('\uff1f'))
278
+ //
279
+ // [SF-001] Scope constraints:
280
+ // - The mid-line '?' detection is effective without False Positive risk only within
281
+ // SEC-001b guard context (questionEndIndex vicinity and upward scan range).
282
+ // - isQuestionLikeLine() is currently module-private (no export).
283
+ // - If this function is exported for external use in the future, consider:
284
+ // (a) Providing a stricter variant (e.g., isStrictQuestionLikeLine()) without mid-line match
285
+ // (b) Separating mid-line match into a SEC-001b-specific helper function
286
+ // (c) Adding URL exclusion logic (/[?&]\w+=/.test(line) to exclude)
287
+ if (line.includes('?') || line.includes('\uff1f'))
288
+ return true;
289
+ // Pattern 2: Lines containing a selection/input keyword.
290
+ // Detects both colon-terminated (e.g., "Select an option:", "Choose a mode:") and
291
+ // non-colon forms (e.g., "Select model") used by CLI prompts (Issue #256).
292
+ //
293
+ // [SF-001] Scope constraints apply:
294
+ // - Effective without False Positive risk only within SEC-001b guard context.
295
+ // - T11h-T11m False Positive lines do not contain QUESTION_KEYWORD_PATTERN keywords.
296
+ // - If this function is exported, consider restricting this pattern to SEC-001b context.
297
+ if (QUESTION_KEYWORD_PATTERN.test(line))
252
298
  return true;
253
- // Pattern 2: Lines ending with colon that contain a selection/input keyword
254
- // Examples: "Select an option:", "Choose a mode:", "Pick one:"
255
- if (line.endsWith(':')) {
256
- if (QUESTION_KEYWORD_PATTERN.test(line))
299
+ return false;
300
+ }
301
+ /**
302
+ * Search upward from a given line index to find a question-like line.
303
+ * Skips empty lines and separator lines (horizontal rules).
304
+ *
305
+ * This function is used by SEC-001b guard to find a question line above
306
+ * questionEndIndex when the questionEndIndex line itself is not a question-like line.
307
+ * This handles cases where the question text wraps across multiple lines or
308
+ * where description lines appear between the question and the numbered options.
309
+ *
310
+ * @param lines - Array of output lines
311
+ * @param startIndex - Starting line index (exclusive, searches startIndex-1 and above)
312
+ * @param scanRange - Maximum number of lines to scan upward (must be >= 0, clamped to MAX_SCAN_RANGE=10)
313
+ * @param lowerBound - Minimum line index (inclusive, scan will not go below this)
314
+ * @returns true if a question-like line is found within the scan range
315
+ *
316
+ * @see IC-256-002: SEC-001b upward scan implementation
317
+ * @see SF-003: Function extraction for readability
318
+ * @see SF-S4-001: scanRange input validation (defensive clamping)
319
+ *
320
+ * ReDoS safe: Uses SEPARATOR_LINE_PATTERN (existing ReDoS safe pattern) and
321
+ * isQuestionLikeLine() (literal character checks + simple alternation pattern).
322
+ * No new regex patterns introduced. (C-S4-001)
323
+ */
324
+ function findQuestionLineInRange(lines, startIndex, scanRange, lowerBound) {
325
+ // [SF-S4-001] Defensive input validation: clamp scanRange to safe bounds.
326
+ // Currently only called with QUESTION_SCAN_RANGE=3, but guards against
327
+ // future misuse if the function is refactored or exported.
328
+ const safeScanRange = Math.min(Math.max(scanRange, 0), 10);
329
+ const scanLimit = Math.max(lowerBound, startIndex - safeScanRange);
330
+ for (let i = startIndex - 1; i >= scanLimit; i--) {
331
+ const candidateLine = lines[i]?.trim() ?? '';
332
+ // Skip empty lines and separator lines (horizontal rules)
333
+ if (!candidateLine || SEPARATOR_LINE_PATTERN.test(candidateLine))
334
+ continue;
335
+ if (isQuestionLikeLine(candidateLine)) {
257
336
  return true;
337
+ }
258
338
  }
259
339
  return false;
260
340
  }
@@ -308,19 +388,103 @@ function isConsecutiveFromOne(numbers) {
308
388
  * @returns true if the line should be treated as a continuation of a previous option
309
389
  */
310
390
  function isContinuationLine(rawLine, line) {
311
- // Indented non-option line.
312
- // Excludes lines ending with '?' or '?' (U+FF1F) because those are typically question lines
391
+ // Lines ending with '?' or full-width '?' (U+FF1F) are typically question lines
313
392
  // (e.g., " Do you want to proceed?", " コピーしたい対象はどれですか?") from CLI tool output
314
- // where both the question and options are 2-space indented. Without this exclusion,
315
- // the question line would be misclassified as a continuation line, causing
316
- // questionEndIndex to remain -1 and Layer 5 SEC-001 to block detection.
393
+ // where both the question and options are 2-space indented. These must NOT be
394
+ // treated as continuation lines, otherwise questionEndIndex remains -1 and
395
+ // Layer 5 SEC-001 blocks detection.
317
396
  const endsWithQuestion = line.endsWith('?') || line.endsWith('\uff1f');
318
- const hasLeadingSpaces = /^\s{2,}[^\d]/.test(rawLine) && !/^\s*\d+\./.test(rawLine) && !endsWithQuestion;
319
- // Short fragment (< 5 chars, excluding question-ending lines)
320
- const isShortFragment = line.length < 5 && !endsWithQuestion;
321
- // Path string continuation: lines starting with / or ~, or alphanumeric-only fragments (2+ chars)
322
- const isPathContinuation = /^[\/~]/.test(line) || (line.length >= 2 && /^[a-zA-Z0-9_-]+$/.test(line));
323
- return !!hasLeadingSpaces || isShortFragment || isPathContinuation;
397
+ // Check 1: Indented non-option line (label text wrapping with indentation).
398
+ // Must have 2+ leading spaces, not start with a number (option line), and not end with '?'.
399
+ if (!endsWithQuestion && /^\s{2,}[^\d]/.test(rawLine) && !/^\s*\d+\./.test(rawLine)) {
400
+ return true;
401
+ }
402
+ // Check 2: Short fragment (< 5 chars, e.g., filename tail).
403
+ // Excludes question-ending lines to prevent misclassifying short questions.
404
+ if (line.length < 5 && !endsWithQuestion) {
405
+ return true;
406
+ }
407
+ // Check 3: Path string continuation (Issue #181).
408
+ // Lines starting with / or ~, or alphanumeric-only fragments (2+ chars).
409
+ if (/^[\/~]/.test(line) || (line.length >= 2 && /^[a-zA-Z0-9_-]+$/.test(line))) {
410
+ return true;
411
+ }
412
+ return false;
413
+ }
414
+ /**
415
+ * Extract question text from the lines around questionEndIndex.
416
+ * Collects non-empty, non-separator lines from up to 5 lines before questionEndIndex
417
+ * through questionEndIndex itself, joining them with spaces.
418
+ *
419
+ * @param lines - Array of output lines
420
+ * @param questionEndIndex - Index of the last line before options, or -1 if not found
421
+ * @returns Extracted question text, or generic fallback if questionEndIndex is -1
422
+ */
423
+ function extractQuestionText(lines, questionEndIndex) {
424
+ if (questionEndIndex < 0) {
425
+ return 'Please select an option:';
426
+ }
427
+ const questionLines = [];
428
+ for (let i = Math.max(0, questionEndIndex - 5); i <= questionEndIndex; i++) {
429
+ const line = lines[i].trim();
430
+ if (line && !SEPARATOR_LINE_PATTERN.test(line)) {
431
+ questionLines.push(line);
432
+ }
433
+ }
434
+ return questionLines.join(' ');
435
+ }
436
+ /**
437
+ * Extract instruction text for the prompt block.
438
+ * Captures the complete AskUserQuestion block including context before the question,
439
+ * option descriptions, and navigation hints.
440
+ *
441
+ * @param lines - Array of output lines
442
+ * @param questionEndIndex - Index of the last line before options, or -1 if not found
443
+ * @param effectiveEnd - End index of non-trailing-empty lines
444
+ * @returns Instruction text string, or undefined if no question line found
445
+ */
446
+ function extractInstructionText(lines, questionEndIndex, effectiveEnd) {
447
+ if (questionEndIndex < 0) {
448
+ return undefined;
449
+ }
450
+ const contextStart = Math.max(0, questionEndIndex - 19);
451
+ const blockLines = lines.slice(contextStart, effectiveEnd)
452
+ .map(l => l.trimEnd());
453
+ const joined = blockLines.join('\n').trim();
454
+ return joined.length > 0 ? joined : undefined;
455
+ }
456
+ /**
457
+ * Build the final PromptDetectionResult for a multiple choice prompt.
458
+ * Maps collected options to the output format, checking each option for
459
+ * text input requirements using TEXT_INPUT_PATTERNS.
460
+ *
461
+ * @param question - Extracted question text
462
+ * @param collectedOptions - Options collected during Pass 2 scanning
463
+ * @param instructionText - Instruction text for the prompt block
464
+ * @param output - Original output text (used for rawContent truncation)
465
+ * @returns PromptDetectionResult with isPrompt: true and multiple_choice data
466
+ */
467
+ function buildMultipleChoiceResult(question, collectedOptions, instructionText, output) {
468
+ return {
469
+ isPrompt: true,
470
+ promptData: {
471
+ type: 'multiple_choice',
472
+ question: question.trim(),
473
+ options: collectedOptions.map(opt => {
474
+ const requiresTextInput = TEXT_INPUT_PATTERNS.some(pattern => pattern.test(opt.label));
475
+ return {
476
+ number: opt.number,
477
+ label: opt.label,
478
+ isDefault: opt.isDefault,
479
+ requiresTextInput,
480
+ };
481
+ }),
482
+ status: 'pending',
483
+ instructionText,
484
+ },
485
+ cleanContent: question.trim(),
486
+ rawContent: truncateRawContent(output.trim()), // Issue #235: complete prompt output (truncated) [MF-001]
487
+ };
324
488
  }
325
489
  /**
326
490
  * Detect multiple choice prompts (numbered list with ❯ indicator)
@@ -399,6 +563,20 @@ function detectMultipleChoicePrompt(output, options) {
399
563
  }
400
564
  // Non-option line handling
401
565
  if (collectedOptions.length > 0 && line && !SEPARATOR_LINE_PATTERN.test(line)) {
566
+ // [MF-001 / Issue #256] Check if line is a question-like line BEFORE
567
+ // continuation check. This preserves isContinuationLine()'s SRP by not
568
+ // mixing question detection into it. Without this pre-check, indented
569
+ // question lines (e.g., " Select model") could be misclassified as
570
+ // continuation lines by isContinuationLine()'s hasLeadingSpaces check.
571
+ //
572
+ // [SF-S4-003] Both this pre-check and SEC-001b upward scan use the same
573
+ // isQuestionLikeLine() function intentionally (DRY). If a question line is
574
+ // caught here, SEC-001b upward scan is not needed (questionEndIndex line
575
+ // itself passes isQuestionLikeLine()).
576
+ if (isQuestionLikeLine(line)) {
577
+ questionEndIndex = i;
578
+ break;
579
+ }
402
580
  // Check if this is a continuation line (indented line between options,
403
581
  // or path/filename fragments from terminal width wrapping - Issue #181)
404
582
  const rawLine = lines[i]; // Original line with indentation preserved
@@ -435,61 +613,23 @@ function detectMultipleChoicePrompt(output, options) {
435
613
  // SEC-001b: Question line exists but is not actually a question/selection request.
436
614
  // Validates that the question line contains a question mark or a selection keyword
437
615
  // with colon, distinguishing "Select an option:" from "Recommendations:".
616
+ //
617
+ // [Issue #256] Enhanced with upward scan via findQuestionLineInRange() (SF-003).
618
+ // When questionEndIndex line itself is not a question-like line, scan upward
619
+ // within QUESTION_SCAN_RANGE to find a question line above it. This handles:
620
+ // - Multi-line question wrapping where ? is on a line above questionEndIndex
621
+ // - Model selection prompts where "Select model" is above description lines
438
622
  const questionLine = lines[questionEndIndex]?.trim() ?? '';
439
623
  if (!isQuestionLikeLine(questionLine)) {
440
- return noPromptResult(output);
441
- }
442
- }
443
- // Extract question text
444
- let question = '';
445
- if (questionEndIndex >= 0) {
446
- // Get all non-empty lines from questionEndIndex up to (but not including) first option
447
- const questionLines = [];
448
- for (let i = Math.max(0, questionEndIndex - 5); i <= questionEndIndex; i++) {
449
- const line = lines[i].trim();
450
- if (line && !SEPARATOR_LINE_PATTERN.test(line)) {
451
- questionLines.push(line);
624
+ // Upward scan: look for a question-like line above questionEndIndex
625
+ if (!findQuestionLineInRange(lines, questionEndIndex, QUESTION_SCAN_RANGE, scanStart)) {
626
+ return noPromptResult(output);
452
627
  }
453
628
  }
454
- question = questionLines.join(' ');
455
- }
456
- else {
457
- // No clear question found - use a generic one
458
- question = 'Please select an option:';
459
- }
460
- // Extract instruction text: full prompt block (context before question through all options/descriptions)
461
- // Captures the complete AskUserQuestion block including option descriptions and navigation hints.
462
- let instructionText;
463
- if (questionEndIndex >= 0) {
464
- const contextStart = Math.max(0, questionEndIndex - 19);
465
- const blockLines = lines.slice(contextStart, effectiveEnd)
466
- .map(l => l.trimEnd());
467
- const joined = blockLines.join('\n').trim();
468
- if (joined.length > 0) {
469
- instructionText = joined;
470
- }
471
629
  }
472
- return {
473
- isPrompt: true,
474
- promptData: {
475
- type: 'multiple_choice',
476
- question: question.trim(),
477
- options: collectedOptions.map(opt => {
478
- // Check if this option requires text input using module-level patterns
479
- const requiresTextInput = TEXT_INPUT_PATTERNS.some(pattern => pattern.test(opt.label));
480
- return {
481
- number: opt.number,
482
- label: opt.label,
483
- isDefault: opt.isDefault,
484
- requiresTextInput,
485
- };
486
- }),
487
- status: 'pending',
488
- instructionText,
489
- },
490
- cleanContent: question.trim(),
491
- rawContent: truncateRawContent(output.trim()), // Issue #235: complete prompt output (truncated) [MF-001]
492
- };
630
+ const question = extractQuestionText(lines, questionEndIndex);
631
+ const instructionText = extractInstructionText(lines, questionEndIndex, effectiveEnd);
632
+ return buildMultipleChoiceResult(question, collectedOptions, instructionText, output);
493
633
  }
494
634
  /**
495
635
  * Get tmux input string for an answer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "commandmate",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Git worktree management with Claude CLI and tmux sessions",
5
5
  "repository": {
6
6
  "type": "git",