prjct-cli 1.7.1 → 1.7.2

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.
@@ -19,6 +19,8 @@ import type {
19
19
  import { StateJsonSchema } from '../schemas/state'
20
20
  import { getTimestamp, toRelative } from '../utils/date-helper'
21
21
  import { md } from '../utils/markdown-builder'
22
+ import type { WorkflowCommand } from '../workflow/state-machine'
23
+ import { workflowStateMachine } from '../workflow/state-machine'
22
24
  import { StorageManager } from './storage-manager'
23
25
 
24
26
  class StateStorage extends StorageManager<StateJson> {
@@ -76,7 +78,11 @@ class StateStorage extends StorageManager<StateJson> {
76
78
  ? '▶️'
77
79
  : subtask.status === 'failed'
78
80
  ? '❌'
79
- : ''
81
+ : subtask.status === 'skipped'
82
+ ? '⏭️'
83
+ : subtask.status === 'blocked'
84
+ ? '🚫'
85
+ : '⏳'
80
86
  const isActive = index === task.currentSubtaskIndex ? ' **← Active**' : ''
81
87
  m.raw(
82
88
  `| ${index + 1} | ${subtask.domain} | ${subtask.description} | ${statusIcon} ${subtask.status}${isActive} | ${subtask.agent} |`
@@ -119,19 +125,38 @@ class StateStorage extends StorageManager<StateJson> {
119
125
  .when(!data.currentTask, (m) => {
120
126
  m.italic('No active task. Use /p:work to start.')
121
127
  })
122
- .maybe(data.previousTask, (m, prev) => {
123
- m.hr()
124
- .h2('Paused')
125
- .bold(prev.description)
126
- .raw(`Paused: ${toRelative(prev.pausedAt)}`)
127
- .maybe(prev.pauseReason, (m, reason) => m.raw(`Reason: ${reason}`))
128
- .blank()
129
- .italic('Use /p:resume to continue')
128
+ .when((data.pausedTasks?.length || 0) > 0 || !!data.previousTask, (m) => {
129
+ const paused = data.pausedTasks?.length
130
+ ? data.pausedTasks
131
+ : data.previousTask
132
+ ? [data.previousTask]
133
+ : []
134
+ if (paused.length === 0) return
135
+ m.hr().h2(`Paused (${paused.length})`)
136
+ paused.forEach((prev, i) => {
137
+ m.raw(`${i + 1}. **${prev.description}**`).raw(` Paused: ${toRelative(prev.pausedAt)}`)
138
+ if (prev.pauseReason) m.raw(` Reason: ${prev.pauseReason}`)
139
+ })
140
+ m.blank().italic('Use /p:resume to continue')
130
141
  })
131
142
  .blank()
132
143
  .build()
133
144
  }
134
145
 
146
+ // =========== Transition Validation ===========
147
+
148
+ /**
149
+ * Validate a state transition through the state machine.
150
+ * Throws if the transition is invalid.
151
+ */
152
+ private validateTransition(state: StateJson, command: WorkflowCommand): void {
153
+ const currentState = workflowStateMachine.getCurrentState(state)
154
+ const result = workflowStateMachine.canTransition(currentState, command)
155
+ if (!result.valid) {
156
+ throw new Error(`${result.error}. ${result.suggestion || ''}`.trim())
157
+ }
158
+ }
159
+
135
160
  // =========== Domain Methods ===========
136
161
 
137
162
  /**
@@ -146,6 +171,9 @@ class StateStorage extends StorageManager<StateJson> {
146
171
  * Start a new task
147
172
  */
148
173
  async startTask(projectId: string, task: Omit<CurrentTask, 'startedAt'>): Promise<CurrentTask> {
174
+ const state = await this.read(projectId)
175
+ this.validateTransition(state, 'task')
176
+
149
177
  const currentTask: CurrentTask = {
150
178
  ...task,
151
179
  startedAt: getTimestamp(),
@@ -179,6 +207,8 @@ class StateStorage extends StorageManager<StateJson> {
179
207
  return null
180
208
  }
181
209
 
210
+ this.validateTransition(state, 'done')
211
+
182
212
  await this.update(projectId, () => ({
183
213
  currentTask: null,
184
214
  previousTask: null,
@@ -196,8 +226,14 @@ class StateStorage extends StorageManager<StateJson> {
196
226
  return completedTask
197
227
  }
198
228
 
229
+ /** Max number of paused tasks (configurable) */
230
+ private maxPausedTasks = 5
231
+
232
+ /** Staleness threshold in days */
233
+ private stalenessThresholdDays = 30
234
+
199
235
  /**
200
- * Pause current task
236
+ * Pause current task — pushes onto pausedTasks[] array
201
237
  */
202
238
  async pauseTask(projectId: string, reason?: string): Promise<PreviousTask | null> {
203
239
  const state = await this.read(projectId)
@@ -206,7 +242,9 @@ class StateStorage extends StorageManager<StateJson> {
206
242
  return null
207
243
  }
208
244
 
209
- const previousTask: PreviousTask = {
245
+ this.validateTransition(state, 'pause')
246
+
247
+ const pausedTask: PreviousTask = {
210
248
  id: state.currentTask.id,
211
249
  description: state.currentTask.description,
212
250
  status: 'paused',
@@ -215,43 +253,67 @@ class StateStorage extends StorageManager<StateJson> {
215
253
  pauseReason: reason,
216
254
  }
217
255
 
256
+ // Get existing paused tasks, migrate from previousTask if needed
257
+ const existingPaused = this.getPausedTasksFromState(state)
258
+
259
+ // Enforce max paused limit
260
+ const pausedTasks = [pausedTask, ...existingPaused].slice(0, this.maxPausedTasks)
261
+
218
262
  await this.update(projectId, () => ({
219
263
  currentTask: null,
220
- previousTask,
264
+ previousTask: null, // deprecated, keep null for compat
265
+ pausedTasks,
221
266
  lastUpdated: getTimestamp(),
222
267
  }))
223
268
 
224
269
  // Publish incremental event
225
270
  await this.publishEvent(projectId, 'task.paused', {
226
- taskId: previousTask.id,
227
- description: previousTask.description,
228
- pausedAt: previousTask.pausedAt,
271
+ taskId: pausedTask.id,
272
+ description: pausedTask.description,
273
+ pausedAt: pausedTask.pausedAt,
229
274
  reason,
275
+ pausedCount: pausedTasks.length,
230
276
  })
231
277
 
232
- return previousTask
278
+ return pausedTask
233
279
  }
234
280
 
235
281
  /**
236
- * Resume paused task
282
+ * Resume most recent paused task (or by ID)
237
283
  */
238
- async resumeTask(projectId: string): Promise<CurrentTask | null> {
284
+ async resumeTask(projectId: string, taskId?: string): Promise<CurrentTask | null> {
239
285
  const state = await this.read(projectId)
240
286
 
241
- if (!state.previousTask) {
287
+ // Migrate from previousTask if pausedTasks is empty
288
+ const pausedTasks = this.getPausedTasksFromState(state)
289
+
290
+ if (pausedTasks.length === 0) {
242
291
  return null
243
292
  }
244
293
 
294
+ this.validateTransition(state, 'resume')
295
+
296
+ // Find target task: by ID or most recent (first in array)
297
+ let targetIndex = 0
298
+ if (taskId) {
299
+ targetIndex = pausedTasks.findIndex((t) => t.id === taskId)
300
+ if (targetIndex === -1) return null
301
+ }
302
+
303
+ const target = pausedTasks[targetIndex]
304
+ const remaining = pausedTasks.filter((_, i) => i !== targetIndex)
305
+
245
306
  const currentTask: CurrentTask = {
246
- id: state.previousTask.id,
247
- description: state.previousTask.description,
307
+ id: target.id,
308
+ description: target.description,
248
309
  startedAt: getTimestamp(),
249
310
  sessionId: generateUUID(),
250
311
  }
251
312
 
252
313
  await this.update(projectId, () => ({
253
314
  currentTask,
254
- previousTask: null,
315
+ previousTask: null, // deprecated, keep null
316
+ pausedTasks: remaining,
255
317
  lastUpdated: getTimestamp(),
256
318
  }))
257
319
 
@@ -260,11 +322,73 @@ class StateStorage extends StorageManager<StateJson> {
260
322
  taskId: currentTask.id,
261
323
  description: currentTask.description,
262
324
  resumedAt: currentTask.startedAt,
325
+ remainingPaused: remaining.length,
263
326
  })
264
327
 
265
328
  return currentTask
266
329
  }
267
330
 
331
+ /**
332
+ * Get paused tasks from state, migrating from legacy previousTask if needed
333
+ */
334
+ private getPausedTasksFromState(state: StateJson): PreviousTask[] {
335
+ const paused = state.pausedTasks || []
336
+
337
+ // Migrate legacy previousTask into array if present
338
+ if (state.previousTask && state.previousTask.status === 'paused') {
339
+ const alreadyInArray = paused.some((t) => t.id === state.previousTask!.id)
340
+ if (!alreadyInArray) {
341
+ return [state.previousTask, ...paused]
342
+ }
343
+ }
344
+
345
+ return paused
346
+ }
347
+
348
+ /**
349
+ * Get stale paused tasks (older than threshold)
350
+ */
351
+ async getStalePausedTasks(projectId: string): Promise<PreviousTask[]> {
352
+ const state = await this.read(projectId)
353
+ const pausedTasks = this.getPausedTasksFromState(state)
354
+ const threshold = Date.now() - this.stalenessThresholdDays * 24 * 60 * 60 * 1000
355
+
356
+ return pausedTasks.filter((t) => new Date(t.pausedAt).getTime() < threshold)
357
+ }
358
+
359
+ /**
360
+ * Archive stale paused tasks (remove from pausedTasks)
361
+ * Returns archived tasks
362
+ */
363
+ async archiveStalePausedTasks(projectId: string): Promise<PreviousTask[]> {
364
+ const state = await this.read(projectId)
365
+ const pausedTasks = this.getPausedTasksFromState(state)
366
+ const threshold = Date.now() - this.stalenessThresholdDays * 24 * 60 * 60 * 1000
367
+
368
+ const stale = pausedTasks.filter((t) => new Date(t.pausedAt).getTime() < threshold)
369
+ const fresh = pausedTasks.filter((t) => new Date(t.pausedAt).getTime() >= threshold)
370
+
371
+ if (stale.length === 0) return []
372
+
373
+ await this.update(projectId, (s) => ({
374
+ ...s,
375
+ pausedTasks: fresh,
376
+ previousTask: null,
377
+ lastUpdated: getTimestamp(),
378
+ }))
379
+
380
+ for (const task of stale) {
381
+ await this.publishEvent(projectId, 'task.archived', {
382
+ taskId: task.id,
383
+ description: task.description,
384
+ pausedAt: task.pausedAt,
385
+ reason: 'staleness',
386
+ })
387
+ }
388
+
389
+ return stale
390
+ }
391
+
268
392
  /**
269
393
  * Clear all task state
270
394
  */
@@ -272,6 +396,7 @@ class StateStorage extends StorageManager<StateJson> {
272
396
  await this.update(projectId, () => ({
273
397
  currentTask: null,
274
398
  previousTask: null,
399
+ pausedTasks: [],
275
400
  lastUpdated: getTimestamp(),
276
401
  }))
277
402
  }
@@ -281,15 +406,25 @@ class StateStorage extends StorageManager<StateJson> {
281
406
  */
282
407
  async hasTask(projectId: string): Promise<boolean> {
283
408
  const state = await this.read(projectId)
284
- return state.currentTask !== null || state.previousTask !== null
409
+ const paused = this.getPausedTasksFromState(state)
410
+ return state.currentTask !== null || paused.length > 0
285
411
  }
286
412
 
287
413
  /**
288
- * Get paused task
414
+ * Get most recently paused task
289
415
  */
290
416
  async getPausedTask(projectId: string): Promise<PreviousTask | null> {
291
417
  const state = await this.read(projectId)
292
- return state.previousTask || null
418
+ const paused = this.getPausedTasksFromState(state)
419
+ return paused[0] || null
420
+ }
421
+
422
+ /**
423
+ * Get all paused tasks
424
+ */
425
+ async getAllPausedTasks(projectId: string): Promise<PreviousTask[]> {
426
+ const state = await this.read(projectId)
427
+ return this.getPausedTasksFromState(state)
293
428
  }
294
429
 
295
430
  // =========== Subtask Methods ===========
@@ -468,19 +603,22 @@ class StateStorage extends StorageManager<StateJson> {
468
603
  async areAllSubtasksComplete(projectId: string): Promise<boolean> {
469
604
  const state = await this.read(projectId)
470
605
  if (!state.currentTask?.subtasks) return true
471
- return state.currentTask.subtasks.every((s) => s.status === 'completed')
606
+ return state.currentTask.subtasks.every(
607
+ (s) => s.status === 'completed' || s.status === 'failed' || s.status === 'skipped'
608
+ )
472
609
  }
473
610
 
474
611
  /**
475
- * Fail current subtask
612
+ * Fail current subtask and advance to next
613
+ * Returns the next subtask (or null if all done/failed)
476
614
  */
477
- async failSubtask(projectId: string, error: string): Promise<void> {
615
+ async failSubtask(projectId: string, error: string): Promise<Subtask | null> {
478
616
  const state = await this.read(projectId)
479
- if (!state.currentTask?.subtasks) return
617
+ if (!state.currentTask?.subtasks) return null
480
618
 
481
619
  const currentIndex = state.currentTask.currentSubtaskIndex || 0
482
620
  const current = state.currentTask.subtasks[currentIndex]
483
- if (!current) return
621
+ if (!current) return null
484
622
 
485
623
  const updatedSubtasks = [...state.currentTask.subtasks]
486
624
  updatedSubtasks[currentIndex] = {
@@ -490,11 +628,30 @@ class StateStorage extends StorageManager<StateJson> {
490
628
  output: `Failed: ${error}`,
491
629
  }
492
630
 
631
+ // Advance to next subtask if available
632
+ const nextIndex = currentIndex + 1
633
+ const total = updatedSubtasks.length
634
+ if (nextIndex < total) {
635
+ updatedSubtasks[nextIndex] = {
636
+ ...updatedSubtasks[nextIndex],
637
+ status: 'in_progress',
638
+ startedAt: getTimestamp(),
639
+ }
640
+ }
641
+
642
+ // Count completed + failed + skipped as "resolved" for progress
643
+ const resolved = updatedSubtasks.filter(
644
+ (s) => s.status === 'completed' || s.status === 'failed' || s.status === 'skipped'
645
+ ).length
646
+ const percentage = Math.round((resolved / total) * 100)
647
+
493
648
  await this.update(projectId, (s) => ({
494
649
  ...s,
495
650
  currentTask: {
496
651
  ...s.currentTask!,
497
652
  subtasks: updatedSubtasks,
653
+ currentSubtaskIndex: nextIndex < total ? nextIndex : currentIndex,
654
+ subtaskProgress: { completed: resolved, total, percentage },
498
655
  },
499
656
  lastUpdated: getTimestamp(),
500
657
  }))
@@ -506,6 +663,117 @@ class StateStorage extends StorageManager<StateJson> {
506
663
  description: current.description,
507
664
  error,
508
665
  })
666
+
667
+ return nextIndex < total ? updatedSubtasks[nextIndex] : null
668
+ }
669
+
670
+ /**
671
+ * Skip current subtask with reason and advance to next
672
+ * Returns the next subtask (or null if all done)
673
+ */
674
+ async skipSubtask(projectId: string, reason: string): Promise<Subtask | null> {
675
+ const state = await this.read(projectId)
676
+ if (!state.currentTask?.subtasks) return null
677
+
678
+ const currentIndex = state.currentTask.currentSubtaskIndex || 0
679
+ const current = state.currentTask.subtasks[currentIndex]
680
+ if (!current) return null
681
+
682
+ const updatedSubtasks = [...state.currentTask.subtasks]
683
+ updatedSubtasks[currentIndex] = {
684
+ ...current,
685
+ status: 'skipped',
686
+ completedAt: getTimestamp(),
687
+ output: `Skipped: ${reason}`,
688
+ skipReason: reason,
689
+ }
690
+
691
+ // Advance to next subtask if available
692
+ const nextIndex = currentIndex + 1
693
+ const total = updatedSubtasks.length
694
+ if (nextIndex < total) {
695
+ updatedSubtasks[nextIndex] = {
696
+ ...updatedSubtasks[nextIndex],
697
+ status: 'in_progress',
698
+ startedAt: getTimestamp(),
699
+ }
700
+ }
701
+
702
+ const resolved = updatedSubtasks.filter(
703
+ (s) => s.status === 'completed' || s.status === 'failed' || s.status === 'skipped'
704
+ ).length
705
+ const percentage = Math.round((resolved / total) * 100)
706
+
707
+ await this.update(projectId, (s) => ({
708
+ ...s,
709
+ currentTask: {
710
+ ...s.currentTask!,
711
+ subtasks: updatedSubtasks,
712
+ currentSubtaskIndex: nextIndex < total ? nextIndex : currentIndex,
713
+ subtaskProgress: { completed: resolved, total, percentage },
714
+ },
715
+ lastUpdated: getTimestamp(),
716
+ }))
717
+
718
+ await this.publishEvent(projectId, 'subtask.skipped', {
719
+ taskId: state.currentTask.id,
720
+ subtaskId: current.id,
721
+ description: current.description,
722
+ reason,
723
+ })
724
+
725
+ return nextIndex < total ? updatedSubtasks[nextIndex] : null
726
+ }
727
+
728
+ /**
729
+ * Block current subtask with reason, allow proceeding to next
730
+ * Returns the next subtask (or null if no more)
731
+ */
732
+ async blockSubtask(projectId: string, blocker: string): Promise<Subtask | null> {
733
+ const state = await this.read(projectId)
734
+ if (!state.currentTask?.subtasks) return null
735
+
736
+ const currentIndex = state.currentTask.currentSubtaskIndex || 0
737
+ const current = state.currentTask.subtasks[currentIndex]
738
+ if (!current) return null
739
+
740
+ const updatedSubtasks = [...state.currentTask.subtasks]
741
+ updatedSubtasks[currentIndex] = {
742
+ ...current,
743
+ status: 'blocked',
744
+ output: `Blocked: ${blocker}`,
745
+ blockReason: blocker,
746
+ }
747
+
748
+ // Advance to next subtask if available (blocked doesn't halt)
749
+ const nextIndex = currentIndex + 1
750
+ const total = updatedSubtasks.length
751
+ if (nextIndex < total) {
752
+ updatedSubtasks[nextIndex] = {
753
+ ...updatedSubtasks[nextIndex],
754
+ status: 'in_progress',
755
+ startedAt: getTimestamp(),
756
+ }
757
+ }
758
+
759
+ await this.update(projectId, (s) => ({
760
+ ...s,
761
+ currentTask: {
762
+ ...s.currentTask!,
763
+ subtasks: updatedSubtasks,
764
+ currentSubtaskIndex: nextIndex < total ? nextIndex : currentIndex,
765
+ },
766
+ lastUpdated: getTimestamp(),
767
+ }))
768
+
769
+ await this.publishEvent(projectId, 'subtask.blocked', {
770
+ taskId: state.currentTask.id,
771
+ subtaskId: current.id,
772
+ description: current.description,
773
+ blocker,
774
+ })
775
+
776
+ return nextIndex < total ? updatedSubtasks[nextIndex] : null
509
777
  }
510
778
  }
511
779
 
@@ -22,6 +22,7 @@ const CMD_DESCRIPTIONS: Record<string, string> = {
22
22
  pause: 'Pause and switch context',
23
23
  resume: 'Continue paused task',
24
24
  ship: 'Ship the feature',
25
+ reopen: 'Reopen for rework',
25
26
  next: 'View task queue',
26
27
  sync: 'Analyze project',
27
28
  bug: 'Report a bug',
@@ -38,6 +39,7 @@ const COMMAND_TO_STATE: Record<string, WorkflowState> = {
38
39
  pause: 'paused',
39
40
  resume: 'working',
40
41
  ship: 'shipped',
42
+ reopen: 'working',
41
43
  next: 'idle',
42
44
  sync: 'idle',
43
45
  init: 'idle',
@@ -3,8 +3,9 @@
3
3
  * Explicit states with valid transitions for prjct workflow.
4
4
  *
5
5
  * States: idle → working → completed → shipped
6
- * ↓↑
7
- * paused
6
+ * ↓↑ ↓↑
7
+ * paused ──────→ shipped (fast-track)
8
+ * completed ──reopen──→ working
8
9
  */
9
10
 
10
11
  // =============================================================================
@@ -13,7 +14,7 @@
13
14
 
14
15
  export type WorkflowState = 'idle' | 'working' | 'paused' | 'completed' | 'shipped'
15
16
 
16
- export type WorkflowCommand = 'task' | 'done' | 'pause' | 'resume' | 'ship' | 'next'
17
+ export type WorkflowCommand = 'task' | 'done' | 'pause' | 'resume' | 'ship' | 'next' | 'reopen'
17
18
 
18
19
  interface StateDefinition {
19
20
  transitions: WorkflowCommand[]
@@ -43,13 +44,13 @@ const WORKFLOW_STATES: Record<WorkflowState, StateDefinition> = {
43
44
  description: 'Task in progress',
44
45
  },
45
46
  paused: {
46
- transitions: ['resume', 'task'],
47
- prompt: 'p. resume Continue | p. task <new> Start different',
47
+ transitions: ['resume', 'task', 'ship'],
48
+ prompt: 'p. resume Continue | p. task <new> Start different | p. ship Ship directly',
48
49
  description: 'Task paused',
49
50
  },
50
51
  completed: {
51
- transitions: ['ship', 'task', 'next'],
52
- prompt: 'p. ship Ship it | p. task <next> Start next',
52
+ transitions: ['ship', 'task', 'next', 'pause', 'reopen'],
53
+ prompt: 'p. ship Ship it | p. task <next> Start next | p. reopen Reopen for rework',
53
54
  description: 'Task completed',
54
55
  },
55
56
  shipped: {
@@ -67,14 +68,22 @@ export class WorkflowStateMachine {
67
68
  /**
68
69
  * Get current state from storage state
69
70
  */
70
- getCurrentState(storageState: { currentTask?: { status?: string } | null }): WorkflowState {
71
+ getCurrentState(storageState: {
72
+ currentTask?: Record<string, unknown> | null
73
+ pausedTasks?: unknown[]
74
+ previousTask?: Record<string, unknown> | null
75
+ }): WorkflowState {
71
76
  const task = storageState?.currentTask
72
77
 
73
78
  if (!task) {
74
- return 'idle'
79
+ // Check if there are paused tasks (array or legacy previousTask)
80
+ const hasPaused =
81
+ (storageState?.pausedTasks?.length || 0) > 0 ||
82
+ storageState?.previousTask?.status === 'paused'
83
+ return hasPaused ? 'paused' : 'idle'
75
84
  }
76
85
 
77
- const status = task.status?.toLowerCase()
86
+ const status = (typeof task.status === 'string' ? task.status : '').toLowerCase()
78
87
 
79
88
  switch (status) {
80
89
  case 'in_progress':
@@ -127,6 +136,8 @@ export class WorkflowStateMachine {
127
136
  return 'working'
128
137
  case 'ship':
129
138
  return 'shipped'
139
+ case 'reopen':
140
+ return 'working'
130
141
  case 'next':
131
142
  return currentState // next doesn't change state
132
143
  default:
@@ -172,6 +183,8 @@ export class WorkflowStateMachine {
172
183
  return 'p. resume Continue paused task'
173
184
  case 'ship':
174
185
  return 'p. ship Ship the feature'
186
+ case 'reopen':
187
+ return 'p. reopen Reopen for rework'
175
188
  case 'next':
176
189
  return 'p. next View task queue'
177
190
  default: