jettypod 4.4.83 → 4.4.84

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.
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
3
+ import { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react';
4
4
  import { createPortal } from 'react-dom';
5
5
  import type { WorkItem } from '@/lib/db';
6
6
 
@@ -27,14 +27,21 @@ interface CardPosition {
27
27
  rect: DOMRect;
28
28
  }
29
29
 
30
+ interface InsertionPreview {
31
+ afterCardId: number | null; // null means insert at beginning
32
+ zoneId: string; // epic zone or drop zone id
33
+ }
34
+
30
35
  interface DragContextType {
31
36
  isDragging: boolean;
32
37
  draggedItem: WorkItem | null;
33
38
  activeDropZone: string | null;
34
39
  activeEpicZone: string | null;
40
+ insertionPreview: InsertionPreview | null;
35
41
  dragPosition: { x: number; y: number };
36
42
  dragOffset: { x: number; y: number };
37
43
  draggedCardWidth: number;
44
+ draggedCardHeight: number;
38
45
  setDraggedItem: (item: WorkItem | null) => void;
39
46
  registerDropZone: (id: string, info: DropZoneInfo) => void;
40
47
  unregisterDropZone: (id: string) => void;
@@ -53,9 +60,11 @@ const DragContext = createContext<DragContextType>({
53
60
  draggedItem: null,
54
61
  activeDropZone: null,
55
62
  activeEpicZone: null,
63
+ insertionPreview: null,
56
64
  dragPosition: { x: 0, y: 0 },
57
65
  dragOffset: { x: 0, y: 0 },
58
66
  draggedCardWidth: 0,
67
+ draggedCardHeight: 0,
59
68
  setDraggedItem: () => {},
60
69
  registerDropZone: () => {},
61
70
  unregisterDropZone: () => {},
@@ -72,19 +81,23 @@ const DragContext = createContext<DragContextType>({
72
81
  interface DragProviderProps {
73
82
  children: ReactNode;
74
83
  renderDragOverlay?: (item: WorkItem) => ReactNode;
84
+ onRemoveFromEpic?: EpicAssignHandler;
75
85
  }
76
86
 
77
- export function DragProvider({ children, renderDragOverlay }: DragProviderProps) {
87
+ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic }: DragProviderProps) {
78
88
  const [draggedItem, setDraggedItem] = useState<WorkItem | null>(null);
79
89
  const [activeDropZone, setActiveDropZone] = useState<string | null>(null);
80
90
  const [activeEpicZone, setActiveEpicZone] = useState<string | null>(null);
91
+ const [insertionPreview, setInsertionPreview] = useState<InsertionPreview | null>(null);
81
92
  const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
82
93
  const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
83
94
  const [draggedCardWidth, setDraggedCardWidth] = useState(0);
95
+ const [draggedCardHeight, setDraggedCardHeight] = useState(0);
84
96
  const dropZonesRef = useRef<Map<string, DropZoneInfo>>(new Map());
85
97
  const epicDropZonesRef = useRef<Map<string, EpicDropZoneInfo>>(new Map());
86
98
  const cardPositionsRef = useRef<Map<number, DOMRect>>(new Map());
87
99
  const pointerPositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
100
+ const draggedItemRef = useRef<WorkItem | null>(null);
88
101
 
89
102
  const registerDropZone = useCallback((id: string, info: DropZoneInfo) => {
90
103
  dropZonesRef.current.set(id, info);
@@ -103,10 +116,12 @@ export function DragProvider({ children, renderDragOverlay }: DragProviderProps)
103
116
  }, []);
104
117
 
105
118
  const registerCardPosition = useCallback((id: number, rect: DOMRect) => {
119
+ console.log('[DragContext] registerCardPosition:', id, 'rect:', { top: rect.top, bottom: rect.bottom });
106
120
  cardPositionsRef.current.set(id, rect);
107
121
  }, []);
108
122
 
109
123
  const unregisterCard = useCallback((id: number) => {
124
+ console.log('[DragContext] unregisterCard:', id);
110
125
  cardPositionsRef.current.delete(id);
111
126
  }, []);
112
127
 
@@ -115,6 +130,7 @@ export function DragProvider({ children, renderDragOverlay }: DragProviderProps)
115
130
  cardPositionsRef.current.forEach((rect, id) => {
116
131
  positions.push({ id, rect });
117
132
  });
133
+ console.log('[DragContext] getCardPositions:', positions.length, 'positions', positions.map(p => ({ id: p.id, midY: (p.rect.top + p.rect.bottom) / 2 })));
118
134
  return positions;
119
135
  }, []);
120
136
 
@@ -142,6 +158,32 @@ export function DragProvider({ children, renderDragOverlay }: DragProviderProps)
142
158
 
143
159
  setActiveDropZone(foundZone);
144
160
  setActiveEpicZone(foundEpicZone);
161
+
162
+ // Calculate insertion preview based on pointer position
163
+ const zoneId = foundEpicZone || foundZone;
164
+ if (zoneId && draggedItemRef.current) {
165
+ const allPositions = Array.from(cardPositionsRef.current.entries())
166
+ .filter(([id]) => id !== draggedItemRef.current?.id)
167
+ .map(([id, rect]) => ({
168
+ id,
169
+ midY: (rect.top + rect.bottom) / 2,
170
+ }))
171
+ .sort((a, b) => a.midY - b.midY);
172
+
173
+ // Find which card the pointer is after
174
+ let afterCardId: number | null = null;
175
+ for (const pos of allPositions) {
176
+ if (y > pos.midY) {
177
+ afterCardId = pos.id;
178
+ } else {
179
+ break;
180
+ }
181
+ }
182
+
183
+ setInsertionPreview({ afterCardId, zoneId });
184
+ } else {
185
+ setInsertionPreview(null);
186
+ }
145
187
  }, []);
146
188
 
147
189
  const startDrag = useCallback((item: WorkItem, cardRect: DOMRect, pointerX: number, pointerY: number) => {
@@ -151,52 +193,106 @@ export function DragProvider({ children, renderDragOverlay }: DragProviderProps)
151
193
  setDragOffset({ x: offsetX, y: offsetY });
152
194
  setDragPosition({ x: pointerX, y: pointerY });
153
195
  setDraggedCardWidth(cardRect.width);
196
+ setDraggedCardHeight(cardRect.height);
154
197
  setDraggedItem(item);
198
+ draggedItemRef.current = item;
155
199
  }, []);
156
200
 
157
201
  const handleDrop = useCallback(async () => {
202
+ console.log('[DragContext] handleDrop called:', {
203
+ draggedItem: draggedItem ? { id: draggedItem.id, parent_id: draggedItem.parent_id, epic_id: draggedItem.epic_id } : null,
204
+ activeEpicZone,
205
+ activeDropZone
206
+ });
158
207
  if (!draggedItem) return;
159
208
 
160
209
  // Check for epic drop zone first (more specific drop target)
161
210
  if (activeEpicZone) {
162
211
  const epicZoneInfo = epicDropZonesRef.current.get(activeEpicZone);
212
+ console.log('[DragContext] epicZoneInfo:', epicZoneInfo ? { epicId: epicZoneInfo.epicId, hasOnReorder: !!epicZoneInfo.onReorder } : null);
163
213
  if (epicZoneInfo) {
164
214
  const currentEpicId = draggedItem.parent_id || draggedItem.epic_id;
215
+ console.log('[DragContext] currentEpicId:', currentEpicId, 'targetEpicId:', epicZoneInfo.epicId);
165
216
 
166
217
  // Same epic - reorder within epic
167
218
  if (currentEpicId === epicZoneInfo.epicId && epicZoneInfo.onReorder) {
219
+ console.log('[DragContext] Same epic - calling onReorder');
168
220
  await epicZoneInfo.onReorder(draggedItem.id, pointerPositionRef.current.y);
169
221
  return;
170
222
  }
171
223
 
172
224
  // Different epic - assign to new epic
173
225
  if (currentEpicId !== epicZoneInfo.epicId) {
226
+ console.log('[DragContext] Different epic - calling onEpicAssign');
174
227
  await epicZoneInfo.onEpicAssign(draggedItem.id, epicZoneInfo.epicId);
175
228
  return;
176
229
  }
177
230
  }
178
231
  }
179
232
 
180
- // Otherwise handle status drop zone
181
- if (!activeDropZone) return;
233
+ // Check if card is being removed from an epic (dropped outside all epic zones)
234
+ const currentEpicId = draggedItem.parent_id || draggedItem.epic_id;
235
+ if (!activeEpicZone && currentEpicId && onRemoveFromEpic) {
236
+ console.log('[DragContext] Removing from epic - card has epic_id but dropped outside epic zones');
237
+ await onRemoveFromEpic(draggedItem.id, null);
238
+ // Continue to also handle status change if needed
239
+ }
240
+
241
+ // Handle status drop zone
242
+ if (!activeDropZone) {
243
+ console.log('[DragContext] No activeDropZone, returning');
244
+ return;
245
+ }
182
246
 
183
247
  const zoneInfo = dropZonesRef.current.get(activeDropZone);
184
- if (!zoneInfo) return;
248
+ if (!zoneInfo) {
249
+ console.log('[DragContext] No zoneInfo for activeDropZone:', activeDropZone);
250
+ return;
251
+ }
252
+
253
+ console.log('[DragContext] Status drop zone:', { targetStatus: zoneInfo.targetStatus, itemStatus: draggedItem.status, hasOnReorder: !!zoneInfo.onReorder });
185
254
 
186
255
  // Check if this is a status change or a reorder
187
256
  if (draggedItem.status !== zoneInfo.targetStatus) {
188
257
  // Status change
258
+ console.log('[DragContext] Status change - calling onDrop');
189
259
  await zoneInfo.onDrop(draggedItem.id, zoneInfo.targetStatus);
190
260
  } else if (zoneInfo.onReorder) {
191
261
  // Reorder within same status
262
+ console.log('[DragContext] Same status - calling onReorder');
192
263
  await zoneInfo.onReorder(draggedItem.id, pointerPositionRef.current.y);
264
+ } else {
265
+ console.log('[DragContext] Same status but no onReorder handler');
193
266
  }
194
- }, [activeDropZone, activeEpicZone, draggedItem]);
267
+ }, [activeDropZone, activeEpicZone, draggedItem, onRemoveFromEpic]);
195
268
 
196
269
  // Calculate overlay position (pointer position minus offset = card top-left)
197
270
  const overlayX = dragPosition.x - dragOffset.x;
198
271
  const overlayY = dragPosition.y - dragOffset.y;
199
272
 
273
+ // Wrap setDraggedItem to also clear ref and preview
274
+ const handleSetDraggedItem = useCallback((item: WorkItem | null) => {
275
+ setDraggedItem(item);
276
+ draggedItemRef.current = item;
277
+ if (!item) {
278
+ setInsertionPreview(null);
279
+ }
280
+ }, []);
281
+
282
+ // Cancel drag on Escape key
283
+ useEffect(() => {
284
+ if (!draggedItem) return;
285
+
286
+ const handleKeyDown = (e: KeyboardEvent) => {
287
+ if (e.key === 'Escape') {
288
+ handleSetDraggedItem(null);
289
+ }
290
+ };
291
+
292
+ window.addEventListener('keydown', handleKeyDown);
293
+ return () => window.removeEventListener('keydown', handleKeyDown);
294
+ }, [draggedItem, handleSetDraggedItem]);
295
+
200
296
  return (
201
297
  <DragContext.Provider
202
298
  value={{
@@ -204,10 +300,12 @@ export function DragProvider({ children, renderDragOverlay }: DragProviderProps)
204
300
  draggedItem,
205
301
  activeDropZone,
206
302
  activeEpicZone,
303
+ insertionPreview,
207
304
  dragPosition,
208
305
  dragOffset,
209
306
  draggedCardWidth,
210
- setDraggedItem,
307
+ draggedCardHeight,
308
+ setDraggedItem: handleSetDraggedItem,
211
309
  registerDropZone,
212
310
  unregisterDropZone,
213
311
  registerEpicDropZone,
@@ -83,6 +83,10 @@ export function DraggableCard({ item, children, disabled = false }: DraggableCar
83
83
  whileDrag={{
84
84
  cursor: 'grabbing',
85
85
  opacity: 0,
86
+ height: 0,
87
+ marginBottom: 0,
88
+ padding: 0,
89
+ overflow: 'hidden',
86
90
  }}
87
91
  onDragStart={handleDragStart}
88
92
  onDrag={handleDrag}
@@ -3,6 +3,7 @@
3
3
  import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
4
4
  import Link from 'next/link';
5
5
  import { useRouter } from 'next/navigation';
6
+ import { AnimatePresence } from 'framer-motion';
6
7
  import type { WorkItem, InFlightItem, KanbanGroup } from '@/lib/db';
7
8
  import type { UndoAction } from '@/lib/undoStack';
8
9
  import { EditableTitle } from './EditableTitle';
@@ -10,6 +11,7 @@ import { CardMenu } from './CardMenu';
10
11
  import { DragProvider, useDragContext } from './DragContext';
11
12
  import { DraggableCard } from './DraggableCard';
12
13
  import { DropZone } from './DropZone';
14
+ import { PlaceholderCard } from './PlaceholderCard';
13
15
 
14
16
  const typeIcons: Record<string, string> = {
15
17
  epic: '🎯',
@@ -257,26 +259,42 @@ interface EpicGroupProps {
257
259
 
258
260
  function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable = true, onTitleSave, onStatusChange, onEpicAssign, onOrderChange }: EpicGroupProps) {
259
261
  const containerRef = useRef<HTMLDivElement>(null);
260
- const { isDragging, draggedItem, activeEpicZone, registerEpicDropZone, unregisterEpicDropZone, getCardPositions } = useDragContext();
262
+ const { isDragging, draggedItem, activeEpicZone, activeDropZone, dragPosition, draggedCardHeight, registerEpicDropZone, unregisterEpicDropZone, getCardPositions } = useDragContext();
261
263
 
262
- // Get item IDs in this epic for filtering
263
- const itemIds = useMemo(() => new Set(items.map(item => item.id)), [items]);
264
+ // Use ref for items to avoid re-registering drop zone when items change
265
+ const itemsRef = useRef(items);
266
+ itemsRef.current = items;
264
267
 
265
- // Handle reorder within this epic - calculate new display_order based on pointer Y
268
+ // Use ref for callbacks to keep drop zone registration stable
269
+ const onOrderChangeRef = useRef(onOrderChange);
270
+ onOrderChangeRef.current = onOrderChange;
271
+
272
+ // Stable reorder handler that reads from refs
266
273
  const handleEpicReorder = useCallback(async (itemId: number, pointerY: number) => {
267
- if (!onOrderChange) return;
274
+ console.log('[EpicGroup] handleEpicReorder called:', { itemId, pointerY, epicId });
275
+ if (!onOrderChangeRef.current) {
276
+ console.log('[EpicGroup] No onOrderChange, returning');
277
+ return;
278
+ }
279
+
280
+ // Get current item IDs from ref
281
+ const itemIds = new Set(itemsRef.current.map(item => item.id));
282
+ console.log('[EpicGroup] itemIds in this epic:', Array.from(itemIds));
268
283
 
269
284
  // Use cached card positions from registry, filtered to this epic's items
270
285
  const allPositions = getCardPositions();
286
+ console.log('[EpicGroup] allPositions:', allPositions.length);
271
287
  const cardPositions = allPositions
272
288
  .filter(pos => itemIds.has(pos.id) && pos.id !== itemId)
273
289
  .map(pos => ({
274
290
  id: pos.id,
275
291
  midY: (pos.rect.top + pos.rect.bottom) / 2,
276
292
  }));
293
+ console.log('[EpicGroup] filtered cardPositions:', cardPositions);
277
294
 
278
295
  // Skip reorder if this is the only item in the epic (no other cards to reorder against)
279
296
  if (cardPositions.length === 0) {
297
+ console.log('[EpicGroup] No other cards to reorder against, returning');
280
298
  return;
281
299
  }
282
300
 
@@ -294,18 +312,21 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
294
312
 
295
313
  // Calculate new display_order using index * 10
296
314
  const newOrder = insertIndex * 10;
315
+ console.log('[EpicGroup] Calculated insertIndex:', insertIndex, 'newOrder:', newOrder, 'for itemId:', itemId);
297
316
 
298
317
  try {
299
- await onOrderChange(itemId, newOrder);
318
+ console.log('[EpicGroup] Calling onOrderChange...');
319
+ await onOrderChangeRef.current(itemId, newOrder);
320
+ console.log('[EpicGroup] onOrderChange completed successfully');
300
321
  } catch (error) {
301
322
  // Log error and notify user - items remain in original order since state wasn't updated
302
- console.error('Failed to reorder item:', error);
323
+ console.error('[EpicGroup] Failed to reorder item:', error);
303
324
  // Show user-friendly error notification
304
325
  alert('Failed to reorder item. Please try again.');
305
326
  }
306
- }, [onOrderChange, getCardPositions, itemIds]);
327
+ }, [getCardPositions]);
307
328
 
308
- // Register as epic drop zone
329
+ // Register as epic drop zone - stable registration that doesn't change with items
309
330
  useEffect(() => {
310
331
  if (!containerRef.current || !onEpicAssign || epicId === null) return;
311
332
 
@@ -314,13 +335,13 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
314
335
  epicId,
315
336
  element: containerRef.current,
316
337
  onEpicAssign,
317
- onReorder: onOrderChange ? handleEpicReorder : undefined,
338
+ onReorder: handleEpicReorder,
318
339
  });
319
340
 
320
341
  return () => {
321
342
  unregisterEpicDropZone(zoneId);
322
343
  };
323
- }, [epicId, onEpicAssign, onOrderChange, handleEpicReorder, registerEpicDropZone, unregisterEpicDropZone]);
344
+ }, [epicId, onEpicAssign, handleEpicReorder, registerEpicDropZone, unregisterEpicDropZone]);
324
345
 
325
346
  // Check if this epic zone is the active drop target
326
347
  const isActiveTarget = activeEpicZone === `epic-${epicId}`;
@@ -335,7 +356,45 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
335
356
  // Show reorder highlight when dragging within same epic (purple)
336
357
  const showReorderHighlight = isActiveTarget && isSameEpic;
337
358
 
338
- if (items.length === 0 && !showHighlight && !showReorderHighlight) return null;
359
+ // For ungrouped section (epicId === null)
360
+ const isUngroupedSection = epicId === null;
361
+ // Check if cursor is over this ungrouped section (not over any epic zone, but over backlog drop zone)
362
+ const isOverUngroupedSection = isUngroupedSection && !activeEpicZone && activeDropZone;
363
+
364
+ // Render the ungrouped zone when dragging from an epic (provides drop target), but only highlight when cursor is over it
365
+ const shouldRenderUngroupedZone = isUngroupedSection && isDragging && draggedItemEpicId !== null;
366
+ const showRemoveFromEpicZone = isOverUngroupedSection && isDragging && draggedItemEpicId !== null;
367
+
368
+ // Show reorder for ungrouped section when dragging an ungrouped card and cursor is over it
369
+ const showUngroupedReorder = isOverUngroupedSection && isDragging && draggedItemEpicId === null;
370
+
371
+ // Calculate insertion preview for this group - only for the active zone
372
+ const showPreview = (showReorderHighlight || showRemoveFromEpicZone || showHighlight || showUngroupedReorder) && draggedItem;
373
+ let insertAfterItemId: number | null | undefined = undefined; // undefined = no preview, null = at beginning
374
+
375
+ if (showPreview && draggedItem) {
376
+ const allPositions = getCardPositions();
377
+ const itemIds = new Set(items.map(item => item.id));
378
+ const groupPositions = allPositions
379
+ .filter(pos => itemIds.has(pos.id) && pos.id !== draggedItem.id)
380
+ .map(pos => ({
381
+ id: pos.id,
382
+ midY: (pos.rect.top + pos.rect.bottom) / 2,
383
+ }))
384
+ .sort((a, b) => a.midY - b.midY);
385
+
386
+ // Find which card the pointer is after
387
+ insertAfterItemId = null; // Default to beginning
388
+ for (const pos of groupPositions) {
389
+ if (dragPosition.y > pos.midY) {
390
+ insertAfterItemId = pos.id;
391
+ } else {
392
+ break;
393
+ }
394
+ }
395
+ }
396
+
397
+ if (items.length === 0 && !showHighlight && !showReorderHighlight && !shouldRenderUngroupedZone) return null;
339
398
 
340
399
  return (
341
400
  <div
@@ -345,6 +404,8 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
345
404
  ? 'ring-2 ring-indigo-400 bg-indigo-100/50 dark:bg-indigo-900/30'
346
405
  : showReorderHighlight
347
406
  ? 'ring-2 ring-purple-400 bg-purple-100/50 dark:bg-purple-900/30'
407
+ : showRemoveFromEpicZone
408
+ ? 'ring-2 ring-orange-400 bg-orange-100/50 dark:bg-orange-900/30'
348
409
  : ''
349
410
  }`}
350
411
  data-epic-id={epicId}
@@ -375,11 +436,43 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
375
436
  )}
376
437
  </div>
377
438
  )}
439
+ {/* Ungrouped section header - shown when dragging from epic */}
440
+ {isUngroupedSection && showRemoveFromEpicZone && items.length === 0 && (
441
+ <div className="flex items-center gap-2 py-3">
442
+ <span className="text-xs font-medium text-orange-600 dark:text-orange-400">
443
+ Drop here to remove from epic
444
+ </span>
445
+ </div>
446
+ )}
447
+ {isUngroupedSection && items.length > 0 && (
448
+ <div className="flex items-center gap-2 mb-2">
449
+ <span className="text-xs font-medium text-zinc-400 dark:text-zinc-500">Ungrouped</span>
450
+ {showRemoveFromEpicZone && (
451
+ <span className="text-xs px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300">
452
+ drop to remove from epic
453
+ </span>
454
+ )}
455
+ </div>
456
+ )}
378
457
  <div className="space-y-2">
458
+ {/* Placeholder at the beginning (insertAfterItemId === null) */}
459
+ <AnimatePresence>
460
+ {insertAfterItemId === null && (
461
+ <PlaceholderCard key="placeholder-start" height={draggedCardHeight} />
462
+ )}
463
+ </AnimatePresence>
379
464
  {items.map((item) => (
380
- <DraggableCard key={item.id} item={item} disabled={!isDraggable}>
381
- <KanbanCard item={item} onTitleSave={onTitleSave} onStatusChange={onStatusChange} />
382
- </DraggableCard>
465
+ <div key={item.id}>
466
+ <DraggableCard item={item} disabled={!isDraggable}>
467
+ <KanbanCard item={item} onTitleSave={onTitleSave} onStatusChange={onStatusChange} />
468
+ </DraggableCard>
469
+ {/* Placeholder after this card */}
470
+ <AnimatePresence>
471
+ {insertAfterItemId === item.id && (
472
+ <PlaceholderCard key={`placeholder-${item.id}`} height={draggedCardHeight} />
473
+ )}
474
+ </AnimatePresence>
475
+ </div>
383
476
  ))}
384
477
  </div>
385
478
  </div>
@@ -422,20 +515,23 @@ interface BacklogDropZoneWrapperProps {
422
515
  function BacklogDropZoneWrapper({ backlog, onStatusChange, onOrderChange, children }: BacklogDropZoneWrapperProps) {
423
516
  const { getCardPositions } = useDragContext();
424
517
 
425
- // Get all backlog item IDs for filtering positions
426
- const backlogItemIds = useMemo(() => {
427
- const ids = new Set<number>();
428
- for (const group of backlog.values()) {
429
- for (const item of group.items) {
430
- ids.add(item.id);
431
- }
432
- }
433
- return ids;
434
- }, [backlog]);
518
+ // Use refs to keep handler stable
519
+ const backlogRef = useRef(backlog);
520
+ backlogRef.current = backlog;
521
+ const onOrderChangeRef = useRef(onOrderChange);
522
+ onOrderChangeRef.current = onOrderChange;
435
523
 
436
524
  // Handle reorder within backlog - calculate new display_order based on pointer Y
437
525
  const handleBacklogReorder = useCallback(async (itemId: number, pointerY: number) => {
438
- if (!onOrderChange) return;
526
+ if (!onOrderChangeRef.current) return;
527
+
528
+ // Get current backlog item IDs from ref
529
+ const backlogItemIds = new Set<number>();
530
+ for (const group of backlogRef.current.values()) {
531
+ for (const item of group.items) {
532
+ backlogItemIds.add(item.id);
533
+ }
534
+ }
439
535
 
440
536
  // Use cached card positions from registry, filtered to backlog items
441
537
  const allPositions = getCardPositions();
@@ -462,8 +558,8 @@ function BacklogDropZoneWrapper({ backlog, onStatusChange, onOrderChange, childr
462
558
  // Use index * 10 to leave room for future insertions
463
559
  const newOrder = insertIndex * 10;
464
560
 
465
- await onOrderChange(itemId, newOrder);
466
- }, [onOrderChange, getCardPositions, backlogItemIds]);
561
+ await onOrderChangeRef.current(itemId, newOrder);
562
+ }, [getCardPositions]);
467
563
 
468
564
  return (
469
565
  <DropZone
@@ -561,7 +657,7 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
561
657
  }, [inFlight]);
562
658
 
563
659
  return (
564
- <DragProvider renderDragOverlay={renderDragOverlay}>
660
+ <DragProvider renderDragOverlay={renderDragOverlay} onRemoveFromEpic={onEpicAssign}>
565
661
  <div className="flex gap-4 overflow-x-auto h-full" data-testid="kanban-board">
566
662
  {/* Backlog Column */}
567
663
  <KanbanColumn title="Backlog" count={backlogCount}>
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+
3
+ import { motion } from 'framer-motion';
4
+
5
+ interface PlaceholderCardProps {
6
+ height: number;
7
+ minHeight?: number;
8
+ }
9
+
10
+ const DEFAULT_MIN_HEIGHT = 40;
11
+
12
+ export function PlaceholderCard({ height, minHeight = DEFAULT_MIN_HEIGHT }: PlaceholderCardProps) {
13
+ const effectiveHeight = Math.max(height, minHeight);
14
+
15
+ return (
16
+ <motion.div
17
+ data-testid="drag-placeholder"
18
+ initial={{ opacity: 0, height: 0 }}
19
+ animate={{ opacity: 0.6, height: effectiveHeight }}
20
+ exit={{ opacity: 0, height: 0 }}
21
+ transition={{ duration: 0.2, ease: 'easeOut' }}
22
+ style={{
23
+ borderRadius: 8,
24
+ border: '2px dashed rgba(100, 116, 139, 0.4)',
25
+ backgroundColor: 'rgba(100, 116, 139, 0.1)',
26
+ marginBottom: 8,
27
+ }}
28
+ />
29
+ );
30
+ }
@@ -146,15 +146,18 @@ function RealTimeKanbanContent({ initialData, initialDecisions }: RealTimeKanban
146
146
  }, [refreshData, data, undoStack]);
147
147
 
148
148
  const handleOrderChange = useCallback(async (id: number, newOrder: number) => {
149
+ console.log('[RealTimeKanban] handleOrderChange called:', { id, newOrder });
149
150
  try {
150
- await fetch(`/api/work/${id}/order`, {
151
+ const response = await fetch(`/api/work/${id}/order`, {
151
152
  method: 'PATCH',
152
153
  headers: { 'Content-Type': 'application/json' },
153
154
  body: JSON.stringify({ display_order: newOrder }),
154
155
  });
156
+ console.log('[RealTimeKanban] Order API response:', response.status, await response.clone().json());
155
157
  await refreshData();
156
- } catch {
157
- // Silently fail order changes
158
+ console.log('[RealTimeKanban] Data refreshed after order change');
159
+ } catch (error) {
160
+ console.error('[RealTimeKanban] Order change failed:', error);
158
161
  }
159
162
  }, [refreshData]);
160
163
 
@@ -337,6 +337,15 @@ export function getKanbanData(doneLimit: number = 50): KanbanData {
337
337
  }
338
338
  }
339
339
 
340
+ // Always ensure an ungrouped entry exists in backlog for drag-drop target
341
+ if (!backlogGroups.has('ungrouped')) {
342
+ backlogGroups.set('ungrouped', {
343
+ epicId: null,
344
+ epicTitle: null,
345
+ items: []
346
+ });
347
+ }
348
+
340
349
  // Sort done items by completed_at DESC (newest first) within each group
341
350
  for (const [, group] of doneGroups) {
342
351
  group.items.sort((a, b) => {
package/jettypod.js CHANGED
@@ -2158,38 +2158,42 @@ switch (command) {
2158
2158
  return null;
2159
2159
  };
2160
2160
 
2161
+ // Kill any stale dashboard processes on ports 3456-3465 BEFORE finding port
2162
+ // This ensures we start fresh on 3456 instead of hopping to higher ports
2163
+ try {
2164
+ const { execSync } = require('child_process');
2165
+ for (let port = BASE_PORT; port < BASE_PORT + 10; port++) {
2166
+ try {
2167
+ const result = execSync(`lsof -ti :${port}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] });
2168
+ const pids = result.trim().split('\n').filter(p => p);
2169
+ for (const pid of pids) {
2170
+ execSync(`kill -9 ${pid}`, { stdio: 'ignore' });
2171
+ }
2172
+ } catch (e) { /* no process on this port */ }
2173
+ }
2174
+ } catch (e) { /* ignore cleanup errors */ }
2175
+
2161
2176
  const availablePort = await findAvailablePort(BASE_PORT);
2162
2177
 
2163
2178
  if (availablePort) {
2164
2179
  // Check if dashboard dependencies are installed (lazy install on first use)
2165
2180
  const dashboardNodeModules = path.join(dashboardPath, 'node_modules');
2166
- const dashboardNextBuild = path.join(dashboardPath, '.next');
2167
2181
 
2168
- if (!fs.existsSync(dashboardNodeModules) || !fs.existsSync(dashboardNextBuild)) {
2169
- console.log('Setting up dashboard for first use...');
2182
+ if (!fs.existsSync(dashboardNodeModules)) {
2183
+ console.log('Installing dashboard dependencies...');
2170
2184
 
2171
2185
  try {
2172
- // Install dependencies
2173
- console.log(' Installing dependencies...');
2174
2186
  const { execSync } = require('child_process');
2175
2187
  execSync('npm install', {
2176
2188
  cwd: dashboardPath,
2177
2189
  stdio: 'inherit'
2178
2190
  });
2179
-
2180
- // Build the dashboard
2181
- console.log(' Building dashboard...');
2182
- execSync('npm run build', {
2183
- cwd: dashboardPath,
2184
- stdio: 'inherit'
2185
- });
2186
-
2187
- console.log('✅ Dashboard setup complete!');
2191
+ console.log('✅ Dependencies installed');
2188
2192
  console.log('');
2189
2193
  } catch (err) {
2190
- console.error('❌ Failed to set up dashboard:', err.message);
2194
+ console.error('❌ Failed to install dependencies:', err.message);
2191
2195
  console.error(' Try running manually:');
2192
- console.error(` cd ${dashboardPath} && npm install && npm run build`);
2196
+ console.error(` cd ${dashboardPath} && npm install`);
2193
2197
  break;
2194
2198
  }
2195
2199
  }
@@ -2211,9 +2215,9 @@ switch (command) {
2211
2215
  });
2212
2216
  wsProcess.unref();
2213
2217
 
2214
- // Start dashboard in background with project path
2215
- console.log('🚀 Starting dashboard...');
2216
- const dashboardProcess = spawn('npm', ['run', 'start', '--', '-p', String(availablePort)], {
2218
+ // Start dashboard in dev mode for live reload
2219
+ console.log('🚀 Starting dashboard (dev mode)...');
2220
+ const dashboardProcess = spawn('npm', ['run', 'dev', '--', '-p', String(availablePort)], {
2217
2221
  cwd: dashboardPath,
2218
2222
  detached: true,
2219
2223
  stdio: 'ignore',
package/lib/db-import.js CHANGED
@@ -2,6 +2,109 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { getDb, getJettypodDir } = require('./database');
4
4
 
5
+ // Valid values for work_items fields
6
+ const VALID_TYPES = ['epic', 'feature', 'chore', 'bug'];
7
+ const VALID_STATUSES = ['backlog', 'todo', 'in_progress', 'done', 'cancelled'];
8
+
9
+ /**
10
+ * Validate a single work item against the schema
11
+ * @param {Object} item - Work item to validate
12
+ * @param {number} index - Index in array for error messages
13
+ * @returns {{valid: boolean, errors: string[]}}
14
+ */
15
+ function validateWorkItem(item, index) {
16
+ const errors = [];
17
+
18
+ if (typeof item !== 'object' || item === null) {
19
+ return { valid: false, errors: [`Item ${index}: must be an object`] };
20
+ }
21
+
22
+ // Required: id must be a number
23
+ if (item.id === undefined || item.id === null) {
24
+ errors.push(`Item ${index}: missing required field 'id'`);
25
+ } else if (typeof item.id !== 'number' || !Number.isInteger(item.id)) {
26
+ errors.push(`Item ${index}: 'id' must be an integer`);
27
+ }
28
+
29
+ // Required: type must be a valid string
30
+ if (item.type === undefined || item.type === null) {
31
+ errors.push(`Item ${index}: missing required field 'type'`);
32
+ } else if (typeof item.type !== 'string') {
33
+ errors.push(`Item ${index}: 'type' must be a string`);
34
+ } else if (!VALID_TYPES.includes(item.type)) {
35
+ errors.push(`Item ${index}: 'type' must be one of: ${VALID_TYPES.join(', ')}`);
36
+ }
37
+
38
+ // Required: title must be a string
39
+ if (item.title === undefined || item.title === null) {
40
+ errors.push(`Item ${index}: missing required field 'title'`);
41
+ } else if (typeof item.title !== 'string') {
42
+ errors.push(`Item ${index}: 'title' must be a string`);
43
+ }
44
+
45
+ // Optional: status must be valid if present
46
+ if (item.status !== undefined && item.status !== null) {
47
+ if (typeof item.status !== 'string') {
48
+ errors.push(`Item ${index}: 'status' must be a string`);
49
+ } else if (!VALID_STATUSES.includes(item.status)) {
50
+ errors.push(`Item ${index}: 'status' must be one of: ${VALID_STATUSES.join(', ')}`);
51
+ }
52
+ }
53
+
54
+ // Optional: parent_id must be integer if present
55
+ if (item.parent_id !== undefined && item.parent_id !== null) {
56
+ if (typeof item.parent_id !== 'number' || !Number.isInteger(item.parent_id)) {
57
+ errors.push(`Item ${index}: 'parent_id' must be an integer`);
58
+ }
59
+ }
60
+
61
+ // Optional: epic_id must be integer if present
62
+ if (item.epic_id !== undefined && item.epic_id !== null) {
63
+ if (typeof item.epic_id !== 'number' || !Number.isInteger(item.epic_id)) {
64
+ errors.push(`Item ${index}: 'epic_id' must be an integer`);
65
+ }
66
+ }
67
+
68
+ return { valid: errors.length === 0, errors };
69
+ }
70
+
71
+ /**
72
+ * Validate work.json data structure and content
73
+ * @param {Object} data - Parsed JSON data
74
+ * @returns {{valid: boolean, errors: string[]}}
75
+ */
76
+ function validateWorkJson(data) {
77
+ const errors = [];
78
+
79
+ if (typeof data !== 'object' || data === null) {
80
+ return { valid: false, errors: ['Data must be an object'] };
81
+ }
82
+
83
+ // work_items must be present and an array
84
+ if (!data.work_items) {
85
+ return { valid: false, errors: ['Missing required field: work_items'] };
86
+ }
87
+
88
+ if (!Array.isArray(data.work_items)) {
89
+ return { valid: false, errors: ['work_items must be an array'] };
90
+ }
91
+
92
+ // Validate each work item
93
+ data.work_items.forEach((item, index) => {
94
+ const result = validateWorkItem(item, index);
95
+ errors.push(...result.errors);
96
+ });
97
+
98
+ // Check for duplicate IDs
99
+ const ids = data.work_items.map(item => item.id).filter(id => id !== undefined);
100
+ const duplicates = ids.filter((id, index) => ids.indexOf(id) !== index);
101
+ if (duplicates.length > 0) {
102
+ errors.push(`Duplicate work item IDs found: ${[...new Set(duplicates)].join(', ')}`);
103
+ }
104
+
105
+ return { valid: errors.length === 0, errors };
106
+ }
107
+
5
108
  /**
6
109
  * Import tables from JSON structure into a database
7
110
  * @param {sqlite3.Database} db - Database connection
@@ -86,6 +189,18 @@ async function importWorkDb() {
86
189
  const jsonContent = fs.readFileSync(jsonPath, 'utf8');
87
190
  const data = JSON.parse(jsonContent);
88
191
 
192
+ // Validate schema before importing
193
+ const validation = validateWorkJson(data);
194
+ if (!validation.valid) {
195
+ console.error('Post-checkout hook warning: work.json schema validation failed');
196
+ validation.errors.slice(0, 5).forEach(err => console.error(` - ${err}`));
197
+ if (validation.errors.length > 5) {
198
+ console.error(` ... and ${validation.errors.length - 5} more errors`);
199
+ }
200
+ console.error(' Database will remain in previous state');
201
+ return jsonPath;
202
+ }
203
+
89
204
  const db = getDb();
90
205
  await importTables(db, data);
91
206
 
@@ -189,5 +304,9 @@ async function importAll() {
189
304
  module.exports = {
190
305
  importWorkDb,
191
306
  importDatabaseDb,
192
- importAll
307
+ importAll,
308
+ validateWorkJson,
309
+ validateWorkItem,
310
+ VALID_TYPES,
311
+ VALID_STATUSES
193
312
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jettypod",
3
- "version": "4.4.83",
3
+ "version": "4.4.84",
4
4
  "description": "AI-powered development workflow manager with TDD, BDD, and automatic test generation",
5
5
  "main": "jettypod.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: bug-planning
3
- description: Guide structured bug investigation with symptom capture, hypothesis testing, and root cause identification. Creates bug work item for direct implementation. Use when user reports a bug, mentions unexpected behavior, or says "investigate bug".
3
+ description: Guide structured bug investigation with symptom capture, hypothesis testing, and root cause identification. Invoked by plan-routing when user reports a bug, mentions unexpected behavior, or describes something broken. (project)
4
4
  ---
5
5
 
6
6
  # Bug Planning Skill
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: chore-planning
3
- description: Guide standalone chore planning with automatic type classification and routing to chore-mode. Use when the implementation approach is obvious - refactoring, bug fixes, infrastructure, or simple enhancements where there's no UX decision to make. Key question - "Does this need UX exploration?" No → chore-planning. Yes → feature-planning instead.
3
+ description: Guide standalone chore planning with automatic type classification and routing to chore-mode. Invoked by plan-routing for substantial technical work - refactoring, infrastructure, migrations, or enhancements where the implementation approach is obvious. (project)
4
4
  ---
5
5
 
6
6
  # Chore Planning Skill
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: epic-planning
3
- description: Guide epic planning with feature brainstorming and optional architectural decision prototyping. Use when user asks to plan an epic, mentions planning features for an epic, says "help me plan epic", or when they just created an epic and want to break it down into features.
3
+ description: Guide epic planning with feature brainstorming and optional architectural decision prototyping. Invoked by plan-routing for large multi-feature initiatives that need to be broken down into features. (project)
4
4
  ---
5
5
 
6
6
  # Epic Planning Skill
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: feature-planning
3
- description: Guide feature planning with UX approach exploration and BDD scenario generation. Use when implementing NEW user-facing behavior that requires UX decisions (multiple valid approaches to discuss). NOT for simple/obvious changes like copy tweaks, styling fixes, or adding straightforward functionality. Key question - "Does this need UX exploration?" Yes → feature-planning.
3
+ description: Guide feature planning with UX approach exploration and BDD scenario generation. Invoked by plan-routing when implementing NEW user-facing behavior that requires UX decisions (multiple valid approaches to explore). (project)
4
4
  ---
5
5
 
6
6
  # Feature Planning Skill
@@ -63,49 +63,8 @@ jettypod workflow start feature-planning <feature-id>
63
63
 
64
64
  This creates an execution record for session resume.
65
65
 
66
- ### Step 1B: Route Simple Improvements
67
-
68
- **CRITICAL:** Before proceeding with full feature planning, determine if this is a simple improvement.
69
-
70
- **Ask the user:**
71
-
72
- ```
73
- Is this:
74
- 1. **Simple improvement** - A basic enhancement to existing functionality (e.g., copy change, styling tweak, minor behavior adjustment)
75
- 2. **New functionality** - Adding new capabilities, even if small
76
-
77
- Simple improvements skip the full feature planning workflow and use a lightweight process.
78
- ```
79
-
80
- **⚡ WAIT for user response.**
81
-
82
- **If user says "simple improvement" (or 1):**
83
-
84
- Display:
85
- ```
86
- This is a simple improvement. Routing to the lightweight simple-improvement workflow...
87
- ```
88
-
89
- **Then IMMEDIATELY invoke simple-improvement using the Skill tool:**
90
- ```
91
- Use the Skill tool with skill: "simple-improvement"
92
- ```
93
-
94
- **Feature-planning skill ends here for simple improvements.** The simple-improvement skill takes over.
95
-
96
- ---
97
-
98
- **If user says "new functionality" (or 2):**
99
-
100
- Display:
101
- ```
102
- Got it - this is new functionality. Let's explore the best approach...
103
- ```
104
-
105
66
  **Proceed to Step 2** (or Step 3 if this is a standalone feature with no parent epic).
106
67
 
107
- ---
108
-
109
68
  ### Step 2: Check Epic Architectural Decisions
110
69
 
111
70
  **Skip this step and proceed to Step 3 if `PARENT_EPIC_ID` from Step 1 is null (standalone feature).**
@@ -0,0 +1,188 @@
1
+ ---
2
+ name: plan-routing
3
+ description: Route work requests to the appropriate planning skill. Use when user describes work they want to do - "build X", "fix Y", "add Z", "plan W". Analyzes intent and complexity to route to bug-planning, chore-planning, feature-planning, epic-planning, or simple-improvement. (project)
4
+ ---
5
+
6
+ # Plan Routing Skill
7
+
8
+ Routes user work requests to the correct planning workflow with minimal friction.
9
+
10
+ ## Instructions
11
+
12
+ When this skill is activated, you are helping route a user's work request to the appropriate planning skill. Follow this structured approach:
13
+
14
+ ### Step 1: Extract Intent Signals
15
+
16
+ From the user's request, identify work type and complexity signals:
17
+
18
+ **Work type signals:**
19
+ | Signal Words | Likely Route |
20
+ |--------------|--------------|
21
+ | fix, bug, broken, crash, error, not working, regression | bug-planning |
22
+ | refactor, rename, move, clean up, upgrade, migrate, infrastructure | chore-planning |
23
+ | add, build, create, implement, new feature, capability, workflow | feature-planning |
24
+ | epic, initiative, project, roadmap, multi-feature | epic-planning |
25
+ | tweak, change, update, adjust + small scope | simple-improvement |
26
+
27
+ **Complexity signals:**
28
+ | Signal | Indicates |
29
+ |--------|-----------|
30
+ | "quick", "small", "just", "simple", "tweak" | Lower complexity |
31
+ | "feature", "workflow", "experience", "redesign" | Higher complexity |
32
+ | References specific file/function | Lower complexity |
33
+ | Describes user-facing behavior change | Higher complexity |
34
+
35
+ ### Step 2: Gather Context (Silent - No Questions)
36
+
37
+ Before routing, quickly probe the codebase for context. **Do NOT ask the user for this information.**
38
+
39
+ ```bash
40
+ # Check for related existing code
41
+ jettypod impact "<key-term-from-request>"
42
+ ```
43
+
44
+ ```bash
45
+ # Check for existing work items
46
+ jettypod backlog | grep -i "<key-term>"
47
+ ```
48
+
49
+ **Assess from results:**
50
+ - Existing code? → Likely modification vs creation
51
+ - Related work items? → May already be planned
52
+ - How many files affected? → Complexity indicator
53
+
54
+ ### Step 3: Route with Stated Assumption
55
+
56
+ **DO NOT ASK a routing question.** State your routing decision with reasoning.
57
+
58
+ ## Routing Decision Tree
59
+
60
+ ```
61
+ User request
62
+
63
+
64
+ Contains bug/fix/broken/error signals?
65
+ ├─► Yes → bug-planning
66
+
67
+
68
+ Describes large multi-feature initiative?
69
+ ├─► Yes → epic-planning
70
+
71
+
72
+ Does this need UX exploration?
73
+ (Multiple valid approaches? User-facing behavior with design choices?)
74
+ ├─► Yes → feature-planning
75
+
76
+
77
+ Is this substantial technical work?
78
+ (Refactoring, infrastructure, migrations, multi-file changes)
79
+ ├─► Yes → chore-planning
80
+
81
+
82
+ Is this a simple, obvious change?
83
+ (Copy, styling, config, minor behavior tweak, one clear approach)
84
+ └─► Yes → simple-improvement
85
+ ```
86
+
87
+ ## Route Definitions
88
+
89
+ | Route | When to Use | Progression |
90
+ |-------|-------------|-------------|
91
+ | **bug-planning** | Something is broken/not working as expected | Investigation → fix |
92
+ | **epic-planning** | Large initiative spanning multiple features | Break down → plan features |
93
+ | **feature-planning** | New user-facing behavior with UX decisions to make | UX exploration → BDD → speed → stable → production |
94
+ | **chore-planning** | Substantial technical work, clear implementation | speed → stable → production |
95
+ | **simple-improvement** | Obvious change, no UX decisions, small scope | Direct implementation |
96
+
97
+ ## Routing Examples
98
+
99
+ **→ bug-planning**
100
+ - "The login button doesn't work on mobile"
101
+ - "Getting a crash when I save"
102
+ - "Users are seeing a 500 error"
103
+
104
+ **→ epic-planning**
105
+ - "We need a full authentication system"
106
+ - "Build out the reporting dashboard"
107
+ - "Plan the v2 API migration"
108
+
109
+ **→ feature-planning**
110
+ - "Add drag-and-drop reordering for cards"
111
+ - "Implement user notifications"
112
+ - "Build a search feature"
113
+
114
+ *(Multiple valid UX approaches exist)*
115
+
116
+ **→ chore-planning**
117
+ - "Refactor the auth module to use the new pattern"
118
+ - "Migrate from Moment.js to date-fns"
119
+ - "Add TypeScript to the utils folder"
120
+ - "Set up CI/CD pipeline"
121
+
122
+ *(Technical work, obvious approach, but substantial)*
123
+
124
+ **→ simple-improvement**
125
+ - "Change the button text from 'Submit' to 'Save'"
126
+ - "Make the error message more descriptive"
127
+ - "Add a loading spinner to the save button"
128
+ - "Change the header color to blue"
129
+ - "Add a tooltip to the settings icon"
130
+
131
+ *(One obvious implementation, no UX exploration needed, just do it)*
132
+
133
+ ## Stating Your Routing Decision
134
+
135
+ **Bug:**
136
+ ```
137
+ Sounds like a bug - [X] isn't working as expected. Let me help you investigate.
138
+ ```
139
+ Then invoke bug-planning skill.
140
+
141
+ **Epic:**
142
+ ```
143
+ This is a larger initiative with multiple features. Let's break it down.
144
+ ```
145
+ Then invoke epic-planning skill.
146
+
147
+ **Feature:**
148
+ ```
149
+ This adds new user-facing behavior with some design choices to explore. Let me suggest a few approaches.
150
+ ```
151
+ Then invoke feature-planning skill.
152
+
153
+ **Chore:**
154
+ ```
155
+ This is technical work with a clear implementation path. Let me help you plan it out.
156
+ ```
157
+ Then invoke chore-planning skill.
158
+
159
+ **Simple improvement:**
160
+ ```
161
+ This is a straightforward change. Let me implement it.
162
+ ```
163
+ Then invoke simple-improvement skill.
164
+
165
+ ---
166
+
167
+ **If genuinely ambiguous (rare - should be <20% of cases):**
168
+ ```
169
+ I could approach "[brief description]" as:
170
+ - A **simple improvement** - just implement the obvious solution
171
+ - A **feature** - explore a few UX approaches first
172
+
173
+ Which feels right?
174
+ ```
175
+
176
+ Only ask when you truly cannot decide based on signals and context.
177
+
178
+ ### Step 4: Invoke Target Skill
179
+
180
+ After stating your routing decision, immediately invoke the appropriate skill using the Skill tool:
181
+ - `bug-planning`
182
+ - `chore-planning`
183
+ - `feature-planning`
184
+ - `epic-planning`
185
+ - `simple-improvement`
186
+
187
+ **This skill ends after invocation.** The target skill takes over.
188
+ # Stable mode: Verified ambiguous request handling exists
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: simple-improvement
3
- description: Guide implementation of simple improvements to existing functionality. Use when feature-planning routes here for basic enhancements like copy changes, styling tweaks, or minor behavior adjustments. (project)
3
+ description: Guide implementation of simple improvements to existing functionality. Invoked by plan-routing for straightforward changes like copy changes, styling tweaks, or minor behavior adjustments where the implementation is obvious. (project)
4
4
  ---
5
5
 
6
6
  # Simple Improvement Skill