jettypod 4.4.74 → 4.4.76

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.
@@ -2,8 +2,9 @@
2
2
 
3
3
  import { useState, useCallback } from 'react';
4
4
  import { KanbanBoard } from './KanbanBoard';
5
+ import { RecentDecisionsWidget } from './RecentDecisionsWidget';
5
6
  import { useWebSocket, type WebSocketMessage } from '../hooks/useWebSocket';
6
- import type { InFlightItem, KanbanGroup } from '@/lib/db';
7
+ import type { InFlightItem, KanbanGroup, Decision } from '@/lib/db';
7
8
 
8
9
  interface KanbanData {
9
10
  inFlight: InFlightItem[];
@@ -17,24 +18,34 @@ interface RealTimeKanbanWrapperProps {
17
18
  backlog: [string, KanbanGroup][];
18
19
  done: [string, KanbanGroup][];
19
20
  };
21
+ initialDecisions: Decision[];
20
22
  }
21
23
 
22
- export function RealTimeKanbanWrapper({ initialData }: RealTimeKanbanWrapperProps) {
24
+ export function RealTimeKanbanWrapper({ initialData, initialDecisions }: RealTimeKanbanWrapperProps) {
23
25
  const [data, setData] = useState<KanbanData>(() => ({
24
26
  inFlight: initialData.inFlight,
25
27
  backlog: new Map(initialData.backlog),
26
28
  done: new Map(initialData.done),
27
29
  }));
30
+ const [decisions, setDecisions] = useState<Decision[]>(initialDecisions);
28
31
  const [statusError, setStatusError] = useState<string | null>(null);
29
32
 
30
33
  const refreshData = useCallback(async () => {
31
- const response = await fetch('/api/kanban');
32
- const newData = await response.json();
34
+ const [kanbanResponse, decisionsResponse] = await Promise.all([
35
+ fetch('/api/kanban'),
36
+ fetch('/api/decisions').catch(() => null),
37
+ ]);
38
+ const newData = await kanbanResponse.json();
33
39
  setData({
34
40
  inFlight: newData.inFlight,
35
41
  backlog: new Map(newData.backlog),
36
42
  done: new Map(newData.done),
37
43
  });
44
+ // Silently retain previous decisions if API fails
45
+ if (decisionsResponse?.ok) {
46
+ const newDecisions = await decisionsResponse.json();
47
+ setDecisions(newDecisions);
48
+ }
38
49
  }, []);
39
50
 
40
51
  const handleMessage = useCallback((message: WebSocketMessage) => {
@@ -75,6 +86,32 @@ export function RealTimeKanbanWrapper({ initialData }: RealTimeKanbanWrapperProp
75
86
  }
76
87
  }, [refreshData]);
77
88
 
89
+ const handleOrderChange = useCallback(async (id: number, newOrder: number) => {
90
+ try {
91
+ await fetch(`/api/work/${id}/order`, {
92
+ method: 'PATCH',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ body: JSON.stringify({ display_order: newOrder }),
95
+ });
96
+ await refreshData();
97
+ } catch {
98
+ // Silently fail order changes
99
+ }
100
+ }, [refreshData]);
101
+
102
+ const handleEpicAssign = useCallback(async (id: number, epicId: number | null) => {
103
+ try {
104
+ await fetch(`/api/work/${id}/epic`, {
105
+ method: 'PATCH',
106
+ headers: { 'Content-Type': 'application/json' },
107
+ body: JSON.stringify({ epic_id: epicId }),
108
+ });
109
+ await refreshData();
110
+ } catch {
111
+ // Silently fail epic assignment
112
+ }
113
+ }, [refreshData]);
114
+
78
115
  const wsUrl = typeof window !== 'undefined'
79
116
  ? `ws://${window.location.hostname}:8080`
80
117
  : 'ws://localhost:8080';
@@ -88,11 +125,11 @@ export function RealTimeKanbanWrapper({ initialData }: RealTimeKanbanWrapperProp
88
125
  const connectionStatus = isConnected ? 'connected' : isReconnecting ? 'reconnecting' : 'disconnected';
89
126
 
90
127
  return (
91
- <div>
128
+ <div className="h-full flex flex-col">
92
129
  {/* Status Error Display */}
93
130
  {statusError && (
94
131
  <div
95
- className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg flex items-center justify-between"
132
+ className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg flex items-center justify-between flex-shrink-0"
96
133
  data-testid="status-error"
97
134
  >
98
135
  <span>{statusError}</span>
@@ -106,7 +143,7 @@ export function RealTimeKanbanWrapper({ initialData }: RealTimeKanbanWrapperProp
106
143
  </div>
107
144
  )}
108
145
  {/* Connection Status Indicator */}
109
- <div className="mb-4 flex items-center gap-2" data-testid="connection-status">
146
+ <div className="mb-4 flex items-center gap-2 flex-shrink-0" data-testid="connection-status">
110
147
  <span
111
148
  className={`w-2 h-2 rounded-full ${
112
149
  connectionStatus === 'connected'
@@ -124,13 +161,22 @@ export function RealTimeKanbanWrapper({ initialData }: RealTimeKanbanWrapperProp
124
161
  : 'Disconnected'}
125
162
  </span>
126
163
  </div>
127
- <KanbanBoard
128
- inFlight={data.inFlight}
129
- backlog={data.backlog}
130
- done={data.done}
131
- onTitleSave={handleTitleSave}
132
- onStatusChange={handleStatusChange}
133
- />
164
+ <div className="flex-1 min-h-0 flex gap-4">
165
+ <div className="flex-1 min-w-0">
166
+ <KanbanBoard
167
+ inFlight={data.inFlight}
168
+ backlog={data.backlog}
169
+ done={data.done}
170
+ onTitleSave={handleTitleSave}
171
+ onStatusChange={handleStatusChange}
172
+ onOrderChange={handleOrderChange}
173
+ onEpicAssign={handleEpicAssign}
174
+ />
175
+ </div>
176
+ <div className="w-60 flex-shrink-0">
177
+ <RecentDecisionsWidget decisions={decisions} />
178
+ </div>
179
+ </div>
134
180
  </div>
135
181
  );
136
182
  }
@@ -0,0 +1,58 @@
1
+ 'use client';
2
+
3
+ import type { Decision } from '@/lib/db';
4
+
5
+ interface RecentDecisionsWidgetProps {
6
+ decisions: Decision[];
7
+ }
8
+
9
+ function formatRelativeDate(dateString: string): string {
10
+ const date = new Date(dateString);
11
+ const now = new Date();
12
+ const diffMs = now.getTime() - date.getTime();
13
+ const diffMins = Math.floor(diffMs / 60000);
14
+ const diffHours = Math.floor(diffMs / 3600000);
15
+ const diffDays = Math.floor(diffMs / 86400000);
16
+
17
+ if (diffMins < 1) return 'just now';
18
+ if (diffMins < 60) return `${diffMins}m ago`;
19
+ if (diffHours < 24) return `${diffHours}h ago`;
20
+ if (diffDays < 7) return `${diffDays}d ago`;
21
+ return date.toLocaleDateString();
22
+ }
23
+
24
+ export function RecentDecisionsWidget({ decisions }: RecentDecisionsWidgetProps) {
25
+ if (decisions.length === 0) {
26
+ return (
27
+ <div className="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 p-4">
28
+ <h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-3">
29
+ Recent Decisions
30
+ </h3>
31
+ <p className="text-sm text-zinc-500 dark:text-zinc-400">No decisions yet</p>
32
+ </div>
33
+ );
34
+ }
35
+
36
+ return (
37
+ <div className="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 p-4">
38
+ <h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-3">
39
+ Recent Decisions
40
+ </h3>
41
+ <div className="space-y-3">
42
+ {decisions.map((decision) => (
43
+ <div key={decision.id} className="flex gap-3">
44
+ <div className="w-1 bg-zinc-300 dark:bg-zinc-600 rounded-full flex-shrink-0" />
45
+ <div className="flex-1 min-w-0">
46
+ <p className="text-sm text-zinc-900 dark:text-zinc-100 line-clamp-2">
47
+ {decision.decision}
48
+ </p>
49
+ <p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1">
50
+ {decision.aspect || 'General'} &middot; #{decision.work_item_id} &middot; {formatRelativeDate(decision.created_at)}
51
+ </p>
52
+ </div>
53
+ </div>
54
+ ))}
55
+ </div>
56
+ </div>
57
+ );
58
+ }
@@ -3,6 +3,7 @@
3
3
 
4
4
  import Database from 'better-sqlite3';
5
5
  import path from 'path';
6
+ import fs from 'fs';
6
7
  import { execSync } from 'child_process';
7
8
 
8
9
  // Types matching JettyPod schema
@@ -19,6 +20,7 @@ export interface WorkItem {
19
20
  phase: string | null;
20
21
  completed_at: string | null;
21
22
  created_at: string;
23
+ display_order: number | null;
22
24
  children?: WorkItem[];
23
25
  chores?: WorkItem[];
24
26
  current_step?: number | null;
@@ -52,6 +54,16 @@ function getDbPath(): string {
52
54
  return path.join(projectRoot, '.jettypod', 'work.db');
53
55
  }
54
56
 
57
+ export function getProjectName(): string {
58
+ const projectRoot = getProjectRoot();
59
+ const configPath = path.join(projectRoot, '.jettypod', 'config.json');
60
+ if (fs.existsSync(configPath)) {
61
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
62
+ return config.name || 'Untitled Project';
63
+ }
64
+ return 'Untitled Project';
65
+ }
66
+
55
67
  function getDb(): Database.Database {
56
68
  const dbPath = getDbPath();
57
69
  return new Database(dbPath, { readonly: true });
@@ -123,6 +135,40 @@ export function getDecisionsForWorkItem(workItemId: number): Decision[] {
123
135
  }
124
136
  }
125
137
 
138
+ export function getRecentDecisions(limit: number = 5): Decision[] {
139
+ const db = getDb();
140
+ try {
141
+ // Combine epic architectural decisions and feature UX decisions
142
+ const decisions = db.prepare(`
143
+ SELECT id, work_item_id, aspect, decision, rationale, created_at
144
+ FROM (
145
+ -- Epic architectural decisions from discovery_decisions table
146
+ SELECT id, work_item_id, aspect, decision, rationale, created_at
147
+ FROM discovery_decisions
148
+
149
+ UNION ALL
150
+
151
+ -- Feature UX decisions from work_items table
152
+ -- Use discovery_completed_at when available, fallback to created_at for older records
153
+ SELECT
154
+ id * -1 as id,
155
+ id as work_item_id,
156
+ 'UX Approach' as aspect,
157
+ discovery_winner as decision,
158
+ discovery_rationale as rationale,
159
+ COALESCE(discovery_completed_at, created_at) as created_at
160
+ FROM work_items
161
+ WHERE discovery_winner IS NOT NULL
162
+ )
163
+ ORDER BY created_at DESC
164
+ LIMIT ?
165
+ `).all(limit) as Decision[];
166
+ return decisions;
167
+ } finally {
168
+ db.close();
169
+ }
170
+ }
171
+
126
172
  // Build a tree structure from flat work items
127
173
  export function buildWorkItemTree(items: WorkItem[]): WorkItem[] {
128
174
  const itemMap = new Map<number, WorkItem>();
@@ -239,7 +285,7 @@ export function getKanbanData(doneLimit: number = 50): KanbanData {
239
285
  // - Bugs if they exist
240
286
  const allItems = db.prepare(`
241
287
  SELECT w.id, w.type, w.title, w.description, w.status, w.parent_id, w.epic_id,
242
- w.branch_name, w.mode, w.phase, w.completed_at, w.created_at,
288
+ w.branch_name, w.mode, w.phase, w.completed_at, w.created_at, w.display_order,
243
289
  p.type as parent_type,
244
290
  wc.current_step, wc.total_steps
245
291
  FROM work_items w
@@ -247,7 +293,7 @@ export function getKanbanData(doneLimit: number = 50): KanbanData {
247
293
  LEFT JOIN workflow_checkpoints wc ON wc.work_item_id = w.id
248
294
  WHERE w.type IN ('feature', 'chore', 'bug')
249
295
  AND (w.parent_id IS NULL OR p.type = 'epic')
250
- ORDER BY w.id
296
+ ORDER BY COALESCE(w.display_order, w.id)
251
297
  `).all() as (WorkItem & { parent_type: string | null })[];
252
298
 
253
299
  const inFlight: InFlightItem[] = [];
@@ -291,21 +337,46 @@ export function getKanbanData(doneLimit: number = 50): KanbanData {
291
337
  }
292
338
  }
293
339
 
294
- // Limit done items
340
+ // Sort done items by completed_at DESC (newest first) within each group
341
+ for (const [, group] of doneGroups) {
342
+ group.items.sort((a, b) => {
343
+ const dateA = a.completed_at ? new Date(a.completed_at).getTime() : 0;
344
+ const dateB = b.completed_at ? new Date(b.completed_at).getTime() : 0;
345
+ return dateB - dateA; // DESC order
346
+ });
347
+ }
348
+
349
+ // Sort epic groups by most recent completion date (newest first)
350
+ const sortedDoneGroups = new Map(
351
+ Array.from(doneGroups.entries()).sort(([, groupA], [, groupB]) => {
352
+ const mostRecentA = groupA.items[0]?.completed_at
353
+ ? new Date(groupA.items[0].completed_at).getTime()
354
+ : 0;
355
+ const mostRecentB = groupB.items[0]?.completed_at
356
+ ? new Date(groupB.items[0].completed_at).getTime()
357
+ : 0;
358
+ return mostRecentB - mostRecentA; // DESC order
359
+ })
360
+ );
361
+
362
+ // Limit done items - build new Map to avoid modifying during iteration
363
+ const limitedDoneGroups = new Map<string, KanbanGroup>();
295
364
  let doneCount = 0;
296
- for (const [key, group] of doneGroups) {
365
+ for (const [key, group] of sortedDoneGroups) {
297
366
  if (doneCount >= doneLimit) {
298
- doneGroups.delete(key);
299
- continue;
367
+ break;
300
368
  }
301
369
  const remaining = doneLimit - doneCount;
302
370
  if (group.items.length > remaining) {
303
- group.items = group.items.slice(0, remaining);
371
+ limitedDoneGroups.set(key, { ...group, items: group.items.slice(0, remaining) });
372
+ doneCount += remaining;
373
+ } else {
374
+ limitedDoneGroups.set(key, group);
375
+ doneCount += group.items.length;
304
376
  }
305
- doneCount += group.items.length;
306
377
  }
307
378
 
308
- return { inFlight, backlog: backlogGroups, done: doneGroups };
379
+ return { inFlight, backlog: backlogGroups, done: limitedDoneGroups };
309
380
  } finally {
310
381
  db.close();
311
382
  }
@@ -335,3 +406,28 @@ export function updateWorkItemStatus(id: number, status: string): boolean {
335
406
  db.close();
336
407
  }
337
408
  }
409
+
410
+ export function updateWorkItemOrder(id: number, displayOrder: number): boolean {
411
+ const db = getWriteDb();
412
+ try {
413
+ const result = db.prepare(`
414
+ UPDATE work_items SET display_order = ? WHERE id = ?
415
+ `).run(displayOrder, id);
416
+ return result.changes > 0;
417
+ } finally {
418
+ db.close();
419
+ }
420
+ }
421
+
422
+ export function updateWorkItemEpic(id: number, epicId: number | null): boolean {
423
+ const db = getWriteDb();
424
+ try {
425
+ // Update parent_id for features/chores to assign them to an epic
426
+ const result = db.prepare(`
427
+ UPDATE work_items SET parent_id = ? WHERE id = ? AND type IN ('feature', 'chore', 'bug')
428
+ `).run(epicId, id);
429
+ return result.changes > 0;
430
+ } finally {
431
+ db.close();
432
+ }
433
+ }
@@ -12,6 +12,7 @@
12
12
  "better-sqlite3": "^12.5.0",
13
13
  "class-variance-authority": "^0.7.1",
14
14
  "clsx": "^2.1.1",
15
+ "framer-motion": "^11.15.0",
15
16
  "lucide-react": "^0.555.0",
16
17
  "next": "16.0.6",
17
18
  "react": "19.2.0",
@@ -28,6 +29,6 @@
28
29
  "eslint-config-next": "16.0.6",
29
30
  "tailwindcss": "^4",
30
31
  "tw-animate-css": "^1.4.0",
31
- "typescript": "^5"
32
+ "typescript": "5.9.3"
32
33
  }
33
34
  }
package/lib/database.js CHANGED
@@ -250,6 +250,7 @@ function initSchema() {
250
250
  db.run(`ALTER TABLE work_items ADD COLUMN needs_discovery INTEGER DEFAULT 0`, handleAlterError('needs_discovery'));
251
251
  db.run(`ALTER TABLE work_items ADD COLUMN worktree_path TEXT`, handleAlterError('worktree_path'));
252
252
  db.run(`ALTER TABLE work_items ADD COLUMN discovery_rationale TEXT`, handleAlterError('discovery_rationale'));
253
+ db.run(`ALTER TABLE work_items ADD COLUMN display_order INTEGER`, handleAlterError('display_order'));
253
254
  db.run(`ALTER TABLE work_items ADD COLUMN architectural_decision TEXT`, async (err) => {
254
255
  handleAlterError('architectural_decision')(err);
255
256
  // Run data migrations after all schema operations complete (skip in test environments)
@@ -279,7 +280,8 @@ function validateSchema(database) {
279
280
  'id', 'type', 'title', 'description', 'status', 'parent_id', 'epic_id',
280
281
  'branch_name', 'file_paths', 'commit_sha', 'mode', 'current', 'phase',
281
282
  'prototype_files', 'discovery_winner', 'discovery_rationale', 'scenario_file',
282
- 'completed_at', 'created_at', 'needs_discovery', 'worktree_path', 'architectural_decision'
283
+ 'completed_at', 'created_at', 'needs_discovery', 'worktree_path', 'architectural_decision',
284
+ 'display_order'
283
285
  ];
284
286
 
285
287
  return new Promise((resolve, reject) => {
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Migration: Add display_order column to work_items table
3
+ *
4
+ * Purpose: Enable custom ordering of work items in the Kanban board.
5
+ * When items are dragged and dropped, their display_order is updated
6
+ * to maintain the user's preferred ordering.
7
+ */
8
+
9
+ module.exports = {
10
+ id: '017-display-order-column',
11
+ description: 'Add display_order column to work_items for Kanban board ordering',
12
+
13
+ async up(db) {
14
+ return new Promise((resolve, reject) => {
15
+ db.run(`
16
+ ALTER TABLE work_items
17
+ ADD COLUMN display_order INTEGER DEFAULT NULL
18
+ `, (err) => {
19
+ if (err) {
20
+ // Column might already exist from a partial migration
21
+ if (err.message && err.message.includes('duplicate column')) {
22
+ return resolve();
23
+ }
24
+ return reject(err);
25
+ }
26
+ resolve();
27
+ });
28
+ });
29
+ },
30
+
31
+ async down(db) {
32
+ // SQLite doesn't support DROP COLUMN in older versions
33
+ // For newer SQLite (3.35+), we can use DROP COLUMN directly
34
+ return new Promise((resolve, reject) => {
35
+ db.run(`
36
+ ALTER TABLE work_items DROP COLUMN display_order
37
+ `, (err) => {
38
+ if (err) {
39
+ // If DROP COLUMN isn't supported, we'd need table recreation
40
+ // but for this simple nullable column, it's safe to leave
41
+ console.warn('Could not drop display_order column:', err.message);
42
+ return resolve();
43
+ }
44
+ resolve();
45
+ });
46
+ });
47
+ }
48
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Migration: Add discovery_completed_at column to work_items table
3
+ *
4
+ * Purpose: Track when feature UX decisions are actually made (via work implement),
5
+ * rather than using created_at which is when the work item was created.
6
+ * This enables proper chronological sorting in the decisions widget.
7
+ */
8
+
9
+ module.exports = {
10
+ id: '019-discovery-completed-at',
11
+ description: 'Add discovery_completed_at column to work_items for accurate decision timestamps',
12
+
13
+ async up(db) {
14
+ return new Promise((resolve, reject) => {
15
+ db.run(`
16
+ ALTER TABLE work_items
17
+ ADD COLUMN discovery_completed_at TEXT
18
+ `, (err) => {
19
+ if (err) {
20
+ // Column might already exist from a partial migration
21
+ if (err.message && err.message.includes('duplicate column')) {
22
+ return resolve();
23
+ }
24
+ return reject(err);
25
+ }
26
+ resolve();
27
+ });
28
+ });
29
+ },
30
+
31
+ async down(db) {
32
+ // SQLite doesn't support DROP COLUMN in older versions
33
+ // For simplicity, we'll leave the column (it's nullable and harmless)
34
+ return Promise.resolve();
35
+ }
36
+ };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Migration: Normalize completed_at timestamps to ISO format
3
+ *
4
+ * Purpose: Convert space-separated timestamps (e.g., "2025-12-12 21:22:40")
5
+ * to ISO format (e.g., "2025-12-12T21:22:40.000Z") for consistent sorting.
6
+ * Old timestamps were written by SQLite datetime('now') as local time.
7
+ */
8
+
9
+ module.exports = {
10
+ id: '020-normalize-timestamps',
11
+ description: 'Normalize completed_at timestamps to ISO format',
12
+
13
+ async up(db) {
14
+ return new Promise((resolve, reject) => {
15
+ // Find all rows with space-separated timestamps (not ISO format)
16
+ db.all(
17
+ `SELECT id, completed_at FROM work_items
18
+ WHERE completed_at IS NOT NULL
19
+ AND completed_at NOT LIKE '%T%'`,
20
+ (err, rows) => {
21
+ if (err) return reject(err);
22
+ if (!rows || rows.length === 0) return resolve();
23
+
24
+ let pending = rows.length;
25
+ let hasError = false;
26
+
27
+ for (const row of rows) {
28
+ if (hasError) continue;
29
+
30
+ // Parse as local time (JS default for space-separated format)
31
+ // Then convert to ISO string (UTC)
32
+ const localDate = new Date(row.completed_at);
33
+ const isoTimestamp = localDate.toISOString();
34
+
35
+ db.run(
36
+ `UPDATE work_items SET completed_at = ? WHERE id = ?`,
37
+ [isoTimestamp, row.id],
38
+ (updateErr) => {
39
+ if (updateErr && !hasError) {
40
+ hasError = true;
41
+ return reject(updateErr);
42
+ }
43
+ pending--;
44
+ if (pending === 0 && !hasError) {
45
+ resolve();
46
+ }
47
+ }
48
+ );
49
+ }
50
+ }
51
+ );
52
+ });
53
+ },
54
+
55
+ async down(db) {
56
+ // No practical way to reverse this - timestamps are still valid
57
+ return Promise.resolve();
58
+ }
59
+ };
@@ -1,5 +1,7 @@
1
1
  const { execSync } = require('child_process');
2
2
  const https = require('https');
3
+ const fs = require('fs');
4
+ const path = require('path');
3
5
  const packageJson = require('../../package.json');
4
6
 
5
7
  /**
@@ -83,6 +85,18 @@ function updateJettyPod(version = 'latest') {
83
85
  stdio: 'inherit'
84
86
  });
85
87
 
88
+ // Clear dashboard .next folder to force rebuild with new assets
89
+ try {
90
+ const globalRoot = execSync('npm root -g', { encoding: 'utf-8' }).trim();
91
+ const dashboardNextPath = path.join(globalRoot, 'jettypod', 'apps', 'dashboard', '.next');
92
+ if (fs.existsSync(dashboardNextPath)) {
93
+ fs.rmSync(dashboardNextPath, { recursive: true, force: true });
94
+ console.log('🧹 Cleared dashboard cache');
95
+ }
96
+ } catch {
97
+ // Non-fatal - dashboard will still work, just might use old cache
98
+ }
99
+
86
100
  return true;
87
101
  } catch (err) {
88
102
  console.log('');
@@ -1726,10 +1726,11 @@ async function mergeWork(options = {}) {
1726
1726
  } else {
1727
1727
  // Chore worktree: mark as done (existing behavior)
1728
1728
  console.log('Marking chore as done...');
1729
+ const completedAt = new Date().toISOString();
1729
1730
  await new Promise((resolve, reject) => {
1730
1731
  db.run(
1731
- `UPDATE work_items SET status = 'done', completed_at = datetime('now') WHERE id = ?`,
1732
- [currentWork.id],
1732
+ `UPDATE work_items SET status = 'done', completed_at = ? WHERE id = ?`,
1733
+ [completedAt, currentWork.id],
1733
1734
  (err) => {
1734
1735
  if (err) return reject(err);
1735
1736
  resolve();
@@ -2173,10 +2173,10 @@ async function main() {
2173
2173
  if (isTransition) {
2174
2174
  // When transitioning to implementation, ensure status is 'todo' (ready for work)
2175
2175
  // This prevents status from being left as NULL or in a discovery-related state
2176
- updateSql = `UPDATE work_items SET phase = 'implementation', mode = 'speed', status = 'todo', scenario_file = ?, prototype_files = ?, discovery_winner = ?, discovery_rationale = ? WHERE id = ?`;
2176
+ updateSql = `UPDATE work_items SET phase = 'implementation', mode = 'speed', status = 'todo', scenario_file = ?, prototype_files = ?, discovery_winner = ?, discovery_rationale = ?, discovery_completed_at = datetime('now') WHERE id = ?`;
2177
2177
  updateParams = [scenarioFileValue, prototypeFilesValue, winnerValue, rationaleValue, featureId];
2178
2178
  } else {
2179
- updateSql = `UPDATE work_items SET prototype_files = ?, discovery_winner = ?, discovery_rationale = ? WHERE id = ?`;
2179
+ updateSql = `UPDATE work_items SET prototype_files = ?, discovery_winner = ?, discovery_rationale = ?, discovery_completed_at = datetime('now') WHERE id = ?`;
2180
2180
  updateParams = [prototypeFilesValue, winnerValue, rationaleValue, featureId];
2181
2181
  }
2182
2182
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jettypod",
3
- "version": "4.4.74",
3
+ "version": "4.4.76",
4
4
  "description": "AI-powered development workflow manager with TDD, BDD, and automatic test generation",
5
5
  "main": "jettypod.js",
6
6
  "bin": {
@@ -63,8 +63,49 @@ 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
+
66
105
  **Proceed to Step 2** (or Step 3 if this is a standalone feature with no parent epic).
67
106
 
107
+ ---
108
+
68
109
  ### Step 2: Check Epic Architectural Decisions
69
110
 
70
111
  **Skip this step and proceed to Step 3 if `PARENT_EPIC_ID` from Step 1 is null (standalone feature).**