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.
- package/CHANGELOG.md +29 -0
- package/core/__tests__/workflow/state-machine.test.ts +216 -0
- package/core/schemas/state.ts +6 -1
- package/core/storage/state-storage.ts +298 -30
- package/core/utils/next-steps.ts +2 -0
- package/core/workflow/state-machine.ts +23 -10
- package/dist/bin/prjct.mjs +404 -174
- package/package.json +1 -1
|
@@ -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
|
-
.
|
|
123
|
-
|
|
124
|
-
.
|
|
125
|
-
.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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:
|
|
227
|
-
description:
|
|
228
|
-
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
|
|
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
|
-
|
|
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:
|
|
247
|
-
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
|
-
|
|
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
|
-
|
|
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(
|
|
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<
|
|
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
|
|
package/core/utils/next-steps.ts
CHANGED
|
@@ -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
|
-
*
|
|
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: {
|
|
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
|
-
|
|
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
|
|
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:
|