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 CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.2] - 2026-02-07
4
+
5
+ ### Bug Fixes
6
+
7
+ - add missing state machine transitions and dead-end states (PRJ-280) (#141)
8
+
9
+
10
+ ## [1.7.2] - 2026-02-07
11
+
12
+ ### Bug Fix
13
+ - **Fix state machine completeness: missing transitions and dead-end states (PRJ-280)**: Added missing transitions (`completed → pause`, `paused → ship`, `completed → reopen`), subtask states (`skipped`, `blocked` with reason tracking), migrated `previousTask` to `pausedTasks[]` array with max limit (5) and staleness detection (30 days), and enforced all transitions through the state machine at the storage level.
14
+
15
+ ### Implementation Details
16
+ Added `reopen` command to `WorkflowCommand` type. Updated `getCurrentState()` to detect paused state from `pausedTasks[]` array and legacy `previousTask`. `failSubtask()` now advances to the next subtask instead of halting. New `skipSubtask(reason)` and `blockSubtask(blocker)` methods mark subtasks and advance. `pauseTask()` pushes onto a `pausedTasks[]` array (max 5), `resumeTask()` pops from array or by ID. `getPausedTasksFromState()` handles backward compat by migrating legacy `previousTask` format. All storage mutation methods (`startTask`, `completeTask`, `pauseTask`, `resumeTask`) validate transitions through the state machine before executing.
17
+
18
+ ### Test Plan
19
+
20
+ #### For QA
21
+ 1. Verify `completed → pause`, `paused → ship`, and `completed → reopen` transitions work
22
+ 2. Start a task with subtasks, call `failSubtask()` — verify it records reason AND advances to next subtask
23
+ 3. Call `skipSubtask(reason)` and `blockSubtask(blocker)` — verify they record reasons and advance
24
+ 4. Pause 3+ tasks — verify `pausedTasks[]` array stores all, respects max limit of 5
25
+ 5. State.json with old `previousTask` format — verify auto-migration into array
26
+ 6. Attempt invalid transition (e.g., `done` from `idle`) — verify error thrown at storage level
27
+
28
+ #### For Users
29
+ **What changed:** Workflow supports reopening completed tasks, shipping paused tasks directly, and multiple paused tasks. Subtask failures auto-advance instead of halting.
30
+ **Breaking changes:** `previousTask` deprecated in favor of `pausedTasks[]`. Backward compat maintained via auto-migration.
31
+
3
32
  ## [1.7.1] - 2026-02-07
4
33
 
5
34
  ### Bug Fixes
@@ -0,0 +1,216 @@
1
+ /**
2
+ * State Machine Tests
3
+ *
4
+ * Tests for workflow state machine transitions:
5
+ * - All valid transitions work
6
+ * - Invalid transitions are rejected
7
+ * - New transitions: completed→paused, paused→shipped, completed→reopen
8
+ * - getCurrentState detects paused tasks from pausedTasks array
9
+ */
10
+
11
+ import { describe, expect, it } from 'bun:test'
12
+ import type { WorkflowCommand, WorkflowState } from '../../workflow/state-machine'
13
+ import { WorkflowStateMachine } from '../../workflow/state-machine'
14
+
15
+ const sm = new WorkflowStateMachine()
16
+
17
+ // =============================================================================
18
+ // getCurrentState
19
+ // =============================================================================
20
+
21
+ describe('getCurrentState', () => {
22
+ it('returns idle when no task and no paused tasks', () => {
23
+ expect(sm.getCurrentState({ currentTask: null })).toBe('idle')
24
+ expect(sm.getCurrentState({})).toBe('idle')
25
+ })
26
+
27
+ it('returns working for in_progress status', () => {
28
+ expect(sm.getCurrentState({ currentTask: { status: 'in_progress' } })).toBe('working')
29
+ expect(sm.getCurrentState({ currentTask: { status: 'working' } })).toBe('working')
30
+ })
31
+
32
+ it('returns completed for completed/done status', () => {
33
+ expect(sm.getCurrentState({ currentTask: { status: 'completed' } })).toBe('completed')
34
+ expect(sm.getCurrentState({ currentTask: { status: 'done' } })).toBe('completed')
35
+ })
36
+
37
+ it('returns shipped for shipped status', () => {
38
+ expect(sm.getCurrentState({ currentTask: { status: 'shipped' } })).toBe('shipped')
39
+ })
40
+
41
+ it('returns paused when currentTask has paused status', () => {
42
+ expect(sm.getCurrentState({ currentTask: { status: 'paused' } })).toBe('paused')
43
+ })
44
+
45
+ it('returns paused when no currentTask but pausedTasks array has entries', () => {
46
+ expect(sm.getCurrentState({ currentTask: null, pausedTasks: [{ id: '1' }] })).toBe('paused')
47
+ })
48
+
49
+ it('returns paused when no currentTask but legacy previousTask is paused', () => {
50
+ expect(sm.getCurrentState({ currentTask: null, previousTask: { status: 'paused' } })).toBe(
51
+ 'paused'
52
+ )
53
+ })
54
+
55
+ it('returns idle when no currentTask and empty pausedTasks', () => {
56
+ expect(sm.getCurrentState({ currentTask: null, pausedTasks: [] })).toBe('idle')
57
+ })
58
+
59
+ it('returns working for unknown status when task exists', () => {
60
+ expect(sm.getCurrentState({ currentTask: { status: 'active' } })).toBe('working')
61
+ expect(sm.getCurrentState({ currentTask: {} })).toBe('working')
62
+ })
63
+ })
64
+
65
+ // =============================================================================
66
+ // canTransition - valid transitions
67
+ // =============================================================================
68
+
69
+ describe('canTransition - valid', () => {
70
+ const validTransitions: [WorkflowState, WorkflowCommand][] = [
71
+ // idle
72
+ ['idle', 'task'],
73
+ ['idle', 'next'],
74
+ // working
75
+ ['working', 'done'],
76
+ ['working', 'pause'],
77
+ // paused
78
+ ['paused', 'resume'],
79
+ ['paused', 'task'],
80
+ ['paused', 'ship'], // NEW: fast-track ship
81
+ // completed
82
+ ['completed', 'ship'],
83
+ ['completed', 'task'],
84
+ ['completed', 'next'],
85
+ ['completed', 'pause'], // NEW: reopen for review
86
+ ['completed', 'reopen'], // NEW: reopen for rework
87
+ // shipped
88
+ ['shipped', 'task'],
89
+ ['shipped', 'next'],
90
+ ]
91
+
92
+ for (const [state, command] of validTransitions) {
93
+ it(`${state} → ${command} is valid`, () => {
94
+ const result = sm.canTransition(state, command)
95
+ expect(result.valid).toBe(true)
96
+ expect(result.error).toBeUndefined()
97
+ })
98
+ }
99
+ })
100
+
101
+ // =============================================================================
102
+ // canTransition - invalid transitions
103
+ // =============================================================================
104
+
105
+ describe('canTransition - invalid', () => {
106
+ const invalidTransitions: [WorkflowState, WorkflowCommand][] = [
107
+ ['idle', 'done'],
108
+ ['idle', 'pause'],
109
+ ['idle', 'resume'],
110
+ ['idle', 'ship'],
111
+ ['idle', 'reopen'],
112
+ ['working', 'task'],
113
+ ['working', 'ship'],
114
+ ['working', 'resume'],
115
+ ['working', 'next'],
116
+ ['working', 'reopen'],
117
+ ['paused', 'done'],
118
+ ['paused', 'pause'],
119
+ ['paused', 'reopen'],
120
+ ['shipped', 'done'],
121
+ ['shipped', 'pause'],
122
+ ['shipped', 'resume'],
123
+ ['shipped', 'ship'],
124
+ ['shipped', 'reopen'],
125
+ ]
126
+
127
+ for (const [state, command] of invalidTransitions) {
128
+ it(`${state} → ${command} is invalid`, () => {
129
+ const result = sm.canTransition(state, command)
130
+ expect(result.valid).toBe(false)
131
+ expect(result.error).toBeDefined()
132
+ expect(result.suggestion).toBeDefined()
133
+ })
134
+ }
135
+ })
136
+
137
+ // =============================================================================
138
+ // getNextState
139
+ // =============================================================================
140
+
141
+ describe('getNextState', () => {
142
+ it('task → working', () => {
143
+ expect(sm.getNextState('idle', 'task')).toBe('working')
144
+ expect(sm.getNextState('paused', 'task')).toBe('working')
145
+ expect(sm.getNextState('completed', 'task')).toBe('working')
146
+ })
147
+
148
+ it('done → completed', () => {
149
+ expect(sm.getNextState('working', 'done')).toBe('completed')
150
+ })
151
+
152
+ it('pause → paused', () => {
153
+ expect(sm.getNextState('working', 'pause')).toBe('paused')
154
+ expect(sm.getNextState('completed', 'pause')).toBe('paused')
155
+ })
156
+
157
+ it('resume → working', () => {
158
+ expect(sm.getNextState('paused', 'resume')).toBe('working')
159
+ })
160
+
161
+ it('ship → shipped', () => {
162
+ expect(sm.getNextState('completed', 'ship')).toBe('shipped')
163
+ expect(sm.getNextState('paused', 'ship')).toBe('shipped')
164
+ })
165
+
166
+ it('reopen → working', () => {
167
+ expect(sm.getNextState('completed', 'reopen')).toBe('working')
168
+ })
169
+
170
+ it('next preserves current state', () => {
171
+ expect(sm.getNextState('idle', 'next')).toBe('idle')
172
+ expect(sm.getNextState('completed', 'next')).toBe('completed')
173
+ })
174
+ })
175
+
176
+ // =============================================================================
177
+ // getValidCommands
178
+ // =============================================================================
179
+
180
+ describe('getValidCommands', () => {
181
+ it('idle allows task, next', () => {
182
+ expect(sm.getValidCommands('idle')).toEqual(['task', 'next'])
183
+ })
184
+
185
+ it('working allows done, pause', () => {
186
+ expect(sm.getValidCommands('working')).toEqual(['done', 'pause'])
187
+ })
188
+
189
+ it('paused allows resume, task, ship', () => {
190
+ expect(sm.getValidCommands('paused')).toEqual(['resume', 'task', 'ship'])
191
+ })
192
+
193
+ it('completed allows ship, task, next, pause, reopen', () => {
194
+ expect(sm.getValidCommands('completed')).toEqual(['ship', 'task', 'next', 'pause', 'reopen'])
195
+ })
196
+
197
+ it('shipped allows task, next', () => {
198
+ expect(sm.getValidCommands('shipped')).toEqual(['task', 'next'])
199
+ })
200
+ })
201
+
202
+ // =============================================================================
203
+ // formatNextSteps
204
+ // =============================================================================
205
+
206
+ describe('formatNextSteps', () => {
207
+ it('includes reopen in completed state steps', () => {
208
+ const steps = sm.formatNextSteps('completed')
209
+ expect(steps.some((s) => s.includes('reopen'))).toBe(true)
210
+ })
211
+
212
+ it('includes ship in paused state steps', () => {
213
+ const steps = sm.formatNextSteps('paused')
214
+ expect(steps.some((s) => s.includes('ship'))).toBe(true)
215
+ })
216
+ })
@@ -24,6 +24,7 @@ export const TaskStatusSchema = z.enum([
24
24
  'blocked',
25
25
  'paused',
26
26
  'failed',
27
+ 'skipped',
27
28
  ])
28
29
  export const ActivityTypeSchema = z.enum([
29
30
  'task_completed',
@@ -59,6 +60,8 @@ export const SubtaskSchema = z.object({
59
60
  completedAt: z.string().optional(), // ISO8601
60
61
  output: z.string().optional(), // Brief output description
61
62
  summary: SubtaskSummarySchema.optional(), // Full summary for context handoff
63
+ skipReason: z.string().optional(), // Why this subtask was skipped
64
+ blockReason: z.string().optional(), // What is blocking this subtask
62
65
  })
63
66
 
64
67
  // Subtask progress tracking
@@ -94,7 +97,8 @@ export const PreviousTaskSchema = z.object({
94
97
 
95
98
  export const StateJsonSchema = z.object({
96
99
  currentTask: CurrentTaskSchema.nullable(),
97
- previousTask: PreviousTaskSchema.nullable().optional(),
100
+ previousTask: PreviousTaskSchema.nullable().optional(), // deprecated: use pausedTasks
101
+ pausedTasks: z.array(PreviousTaskSchema).optional(), // replaces previousTask
98
102
  lastUpdated: z.string(),
99
103
  })
100
104
 
@@ -190,6 +194,7 @@ export const safeParseQueue = (data: unknown) => QueueJsonSchema.safeParse(data)
190
194
 
191
195
  export const DEFAULT_STATE: StateJson = {
192
196
  currentTask: null,
197
+ pausedTasks: [],
193
198
  lastUpdated: '',
194
199
  }
195
200