@@ -9,13 +9,78 @@ interface ChangeInfo {
99 name : string ;
1010 completedTasks : number ;
1111 totalTasks : number ;
12+ lastModified : Date ;
13+ }
14+
15+ interface ListOptions {
16+ sort ?: 'recent' | 'name' ;
17+ json ?: boolean ;
18+ }
19+
20+ /**
21+ * Get the most recent modification time of any file in a directory (recursive).
22+ * Falls back to the directory's own mtime if no files are found.
23+ */
24+ async function getLastModified ( dirPath : string ) : Promise < Date > {
25+ let latest : Date | null = null ;
26+
27+ async function walk ( dir : string ) : Promise < void > {
28+ const entries = await fs . readdir ( dir , { withFileTypes : true } ) ;
29+ for ( const entry of entries ) {
30+ const fullPath = path . join ( dir , entry . name ) ;
31+ if ( entry . isDirectory ( ) ) {
32+ await walk ( fullPath ) ;
33+ } else {
34+ const stat = await fs . stat ( fullPath ) ;
35+ if ( latest === null || stat . mtime > latest ) {
36+ latest = stat . mtime ;
37+ }
38+ }
39+ }
40+ }
41+
42+ await walk ( dirPath ) ;
43+
44+ // If no files found, use the directory's own modification time
45+ if ( latest === null ) {
46+ const dirStat = await fs . stat ( dirPath ) ;
47+ return dirStat . mtime ;
48+ }
49+
50+ return latest ;
51+ }
52+
53+ /**
54+ * Format a date as relative time (e.g., "2 hours ago", "3 days ago")
55+ */
56+ function formatRelativeTime ( date : Date ) : string {
57+ const now = new Date ( ) ;
58+ const diffMs = now . getTime ( ) - date . getTime ( ) ;
59+ const diffSecs = Math . floor ( diffMs / 1000 ) ;
60+ const diffMins = Math . floor ( diffSecs / 60 ) ;
61+ const diffHours = Math . floor ( diffMins / 60 ) ;
62+ const diffDays = Math . floor ( diffHours / 24 ) ;
63+
64+ if ( diffDays > 30 ) {
65+ return date . toLocaleDateString ( ) ;
66+ } else if ( diffDays > 0 ) {
67+ return `${ diffDays } d ago` ;
68+ } else if ( diffHours > 0 ) {
69+ return `${ diffHours } h ago` ;
70+ } else if ( diffMins > 0 ) {
71+ return `${ diffMins } m ago` ;
72+ } else {
73+ return 'just now' ;
74+ }
1275}
1376
1477export class ListCommand {
15- async execute ( targetPath : string = '.' , mode : 'changes' | 'specs' = 'changes' ) : Promise < void > {
78+ async execute ( targetPath : string = '.' , mode : 'changes' | 'specs' = 'changes' , options : ListOptions = { } ) : Promise < void > {
79+ const { sort = 'recent' , json = false } = options ;
80+
1681 if ( mode === 'changes' ) {
1782 const changesDir = path . join ( targetPath , 'openspec' , 'changes' ) ;
18-
83+
1984 // Check if changes directory exists
2085 try {
2186 await fs . access ( changesDir ) ;
@@ -30,24 +95,48 @@ export class ListCommand {
3095 . map ( entry => entry . name ) ;
3196
3297 if ( changeDirs . length === 0 ) {
33- console . log ( 'No active changes found.' ) ;
98+ if ( json ) {
99+ console . log ( JSON . stringify ( { changes : [ ] } ) ) ;
100+ } else {
101+ console . log ( 'No active changes found.' ) ;
102+ }
34103 return ;
35104 }
36105
37106 // Collect information about each change
38107 const changes : ChangeInfo [ ] = [ ] ;
39-
108+
40109 for ( const changeDir of changeDirs ) {
41110 const progress = await getTaskProgressForChange ( changesDir , changeDir ) ;
111+ const changePath = path . join ( changesDir , changeDir ) ;
112+ const lastModified = await getLastModified ( changePath ) ;
42113 changes . push ( {
43114 name : changeDir ,
44115 completedTasks : progress . completed ,
45- totalTasks : progress . total
116+ totalTasks : progress . total ,
117+ lastModified
46118 } ) ;
47119 }
48120
49- // Sort alphabetically by name
50- changes . sort ( ( a , b ) => a . name . localeCompare ( b . name ) ) ;
121+ // Sort by preference (default: recent first)
122+ if ( sort === 'recent' ) {
123+ changes . sort ( ( a , b ) => b . lastModified . getTime ( ) - a . lastModified . getTime ( ) ) ;
124+ } else {
125+ changes . sort ( ( a , b ) => a . name . localeCompare ( b . name ) ) ;
126+ }
127+
128+ // JSON output for programmatic use
129+ if ( json ) {
130+ const jsonOutput = changes . map ( c => ( {
131+ name : c . name ,
132+ completedTasks : c . completedTasks ,
133+ totalTasks : c . totalTasks ,
134+ lastModified : c . lastModified . toISOString ( ) ,
135+ status : c . totalTasks === 0 ? 'no-tasks' : c . completedTasks === c . totalTasks ? 'complete' : 'in-progress'
136+ } ) ) ;
137+ console . log ( JSON . stringify ( { changes : jsonOutput } , null , 2 ) ) ;
138+ return ;
139+ }
51140
52141 // Display results
53142 console . log ( 'Changes:' ) ;
@@ -56,7 +145,8 @@ export class ListCommand {
56145 for ( const change of changes ) {
57146 const paddedName = change . name . padEnd ( nameWidth ) ;
58147 const status = formatTaskStatus ( { total : change . totalTasks , completed : change . completedTasks } ) ;
59- console . log ( `${ padding } ${ paddedName } ${ status } ` ) ;
148+ const timeAgo = formatRelativeTime ( change . lastModified ) ;
149+ console . log ( `${ padding } ${ paddedName } ${ status . padEnd ( 12 ) } ${ timeAgo } ` ) ;
60150 }
61151 return ;
62152 }
0 commit comments