prjct-cli 1.21.0 → 1.22.0
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 +97 -0
- package/README.md +41 -0
- package/core/__tests__/storage/state-storage-feedback.test.ts +463 -0
- package/core/__tests__/storage/state-storage-history.test.ts +469 -0
- package/core/commands/workflow.ts +5 -2
- package/core/schemas/state.ts +43 -0
- package/core/services/agent-generator.ts +70 -1
- package/core/services/sync-service.ts +115 -4
- package/core/storage/state-storage.ts +190 -3
- package/dist/bin/prjct.mjs +256 -10
- package/package.json +1 -1
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State Storage Task History Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for task history functionality in StateStorage:
|
|
5
|
+
* - Task history push on completion
|
|
6
|
+
* - FIFO eviction (max 20 entries)
|
|
7
|
+
* - Backward compatibility (undefined taskHistory)
|
|
8
|
+
* - Accessor methods (getTaskHistory, getMostRecentTask, getTaskHistoryByType)
|
|
9
|
+
* - Context injection (markdown generation with filtering)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
13
|
+
import fs from 'node:fs/promises'
|
|
14
|
+
import os from 'node:os'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import pathManager from '../../infrastructure/path-manager'
|
|
17
|
+
import type { CurrentTask, StateJson } from '../../schemas/state'
|
|
18
|
+
import { prjctDb } from '../../storage/database'
|
|
19
|
+
import { stateStorage } from '../../storage/state-storage'
|
|
20
|
+
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// Test Setup
|
|
23
|
+
// =============================================================================
|
|
24
|
+
|
|
25
|
+
let tmpRoot: string | null = null
|
|
26
|
+
let testProjectId: string
|
|
27
|
+
|
|
28
|
+
// Mock pathManager to use temp directory
|
|
29
|
+
const originalGetGlobalProjectPath = pathManager.getGlobalProjectPath.bind(pathManager)
|
|
30
|
+
const originalGetStoragePath = pathManager.getStoragePath.bind(pathManager)
|
|
31
|
+
const originalGetFilePath = pathManager.getFilePath.bind(pathManager)
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
// Create temp directory for test isolation
|
|
35
|
+
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'prjct-history-test-'))
|
|
36
|
+
testProjectId = `test-history-${Date.now()}`
|
|
37
|
+
|
|
38
|
+
// Mock pathManager to use temp directory
|
|
39
|
+
pathManager.getGlobalProjectPath = (projectId: string) => {
|
|
40
|
+
return path.join(tmpRoot!, projectId)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
pathManager.getStoragePath = (projectId: string, filename: string) => {
|
|
44
|
+
return path.join(tmpRoot!, projectId, 'storage', filename)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
pathManager.getFilePath = (projectId: string, layer: string, filename: string) => {
|
|
48
|
+
return path.join(tmpRoot!, projectId, layer, filename)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Create storage and sync directories
|
|
52
|
+
const storagePath = pathManager.getStoragePath(testProjectId, '')
|
|
53
|
+
await fs.mkdir(storagePath, { recursive: true })
|
|
54
|
+
|
|
55
|
+
const syncPath = path.join(tmpRoot!, testProjectId, 'sync')
|
|
56
|
+
await fs.mkdir(syncPath, { recursive: true })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
afterEach(async () => {
|
|
60
|
+
// Close SQLite connections before cleanup
|
|
61
|
+
prjctDb.close()
|
|
62
|
+
|
|
63
|
+
// Restore original pathManager methods
|
|
64
|
+
pathManager.getGlobalProjectPath = originalGetGlobalProjectPath
|
|
65
|
+
pathManager.getStoragePath = originalGetStoragePath
|
|
66
|
+
pathManager.getFilePath = originalGetFilePath
|
|
67
|
+
|
|
68
|
+
// Clean up temp directory
|
|
69
|
+
if (tmpRoot) {
|
|
70
|
+
await fs.rm(tmpRoot, { recursive: true, force: true })
|
|
71
|
+
tmpRoot = null
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// =============================================================================
|
|
76
|
+
// Helper Functions
|
|
77
|
+
// =============================================================================
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Create a mock task for testing
|
|
81
|
+
*/
|
|
82
|
+
function createMockTask(
|
|
83
|
+
overrides: Partial<CurrentTask> & Record<string, unknown> = {}
|
|
84
|
+
): CurrentTask {
|
|
85
|
+
return {
|
|
86
|
+
id: `task-${Date.now()}`,
|
|
87
|
+
description: 'Test task',
|
|
88
|
+
startedAt: new Date().toISOString(),
|
|
89
|
+
sessionId: `session-${Date.now()}`,
|
|
90
|
+
...overrides,
|
|
91
|
+
} as CurrentTask
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Complete a task and return the state
|
|
96
|
+
*/
|
|
97
|
+
async function completeTaskAndGetState(projectId: string, task: CurrentTask): Promise<StateJson> {
|
|
98
|
+
// Start the task
|
|
99
|
+
await stateStorage.startTask(projectId, task)
|
|
100
|
+
|
|
101
|
+
// Complete the task
|
|
102
|
+
await stateStorage.completeTask(projectId)
|
|
103
|
+
|
|
104
|
+
// Return the state
|
|
105
|
+
return await stateStorage.read(projectId)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// =============================================================================
|
|
109
|
+
// Tests: Task History Push on Completion
|
|
110
|
+
// =============================================================================
|
|
111
|
+
|
|
112
|
+
describe('Task History - Push on Completion', () => {
|
|
113
|
+
it('should add completed task to taskHistory array', async () => {
|
|
114
|
+
const task = createMockTask({
|
|
115
|
+
description: 'Test task 1',
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const state = await completeTaskAndGetState(testProjectId, task)
|
|
119
|
+
|
|
120
|
+
expect(state.taskHistory).toBeDefined()
|
|
121
|
+
expect(state.taskHistory?.length).toBe(1)
|
|
122
|
+
expect(state.taskHistory![0].title).toBe('Test task 1')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('should include all required metadata in history entry', async () => {
|
|
126
|
+
const task = createMockTask({
|
|
127
|
+
description: 'Test task with metadata',
|
|
128
|
+
linearId: 'PRJ-123',
|
|
129
|
+
linearUuid: 'uuid-123',
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const state = await completeTaskAndGetState(testProjectId, task)
|
|
133
|
+
|
|
134
|
+
const entry = state.taskHistory![0]
|
|
135
|
+
expect(entry.taskId).toBe(task.id)
|
|
136
|
+
expect(entry.title).toBe('Test task with metadata')
|
|
137
|
+
expect(entry.startedAt).toBeDefined() // startedAt is set by startTask()
|
|
138
|
+
expect(entry.completedAt).toBeDefined()
|
|
139
|
+
expect(entry.subtaskCount).toBe(0)
|
|
140
|
+
expect(entry.subtaskSummaries).toEqual([])
|
|
141
|
+
expect(entry.outcome).toBe('Task completed')
|
|
142
|
+
expect(entry.linearId).toBe('PRJ-123')
|
|
143
|
+
expect(entry.linearUuid).toBe('uuid-123')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('should extract subtask summaries from completed task', async () => {
|
|
147
|
+
const task = createMockTask({
|
|
148
|
+
description: 'Task with subtasks',
|
|
149
|
+
subtasks: [
|
|
150
|
+
{
|
|
151
|
+
id: 'subtask-1',
|
|
152
|
+
description: 'First subtask',
|
|
153
|
+
domain: 'backend',
|
|
154
|
+
agent: 'backend.md',
|
|
155
|
+
status: 'completed',
|
|
156
|
+
dependsOn: [],
|
|
157
|
+
summary: {
|
|
158
|
+
title: 'Subtask 1 Complete',
|
|
159
|
+
description: 'Did some work',
|
|
160
|
+
filesChanged: [{ path: 'file1.ts', action: 'modified' }],
|
|
161
|
+
whatWasDone: ['Made changes'],
|
|
162
|
+
outputForNextAgent: 'Ready for next step',
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
id: 'subtask-2',
|
|
167
|
+
description: 'Second subtask',
|
|
168
|
+
domain: 'backend',
|
|
169
|
+
agent: 'backend.md',
|
|
170
|
+
status: 'pending',
|
|
171
|
+
dependsOn: [],
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const state = await completeTaskAndGetState(testProjectId, task)
|
|
177
|
+
|
|
178
|
+
const entry = state.taskHistory![0]
|
|
179
|
+
expect(entry.subtaskCount).toBe(2)
|
|
180
|
+
expect(entry.subtaskSummaries.length).toBe(1) // Only completed with summary
|
|
181
|
+
expect(entry.subtaskSummaries[0].title).toBe('Subtask 1 Complete')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('should preserve order - newest entries first', async () => {
|
|
185
|
+
// Complete 3 tasks
|
|
186
|
+
for (let i = 1; i <= 3; i++) {
|
|
187
|
+
const task = createMockTask({
|
|
188
|
+
description: `Task ${i}`,
|
|
189
|
+
})
|
|
190
|
+
await completeTaskAndGetState(testProjectId, task)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const state = await stateStorage.read(testProjectId)
|
|
194
|
+
|
|
195
|
+
expect(state.taskHistory?.length).toBe(3)
|
|
196
|
+
expect(state.taskHistory![0].title).toBe('Task 3') // Newest first
|
|
197
|
+
expect(state.taskHistory![1].title).toBe('Task 2')
|
|
198
|
+
expect(state.taskHistory![2].title).toBe('Task 1') // Oldest last
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// =============================================================================
|
|
203
|
+
// Tests: FIFO Eviction
|
|
204
|
+
// =============================================================================
|
|
205
|
+
|
|
206
|
+
describe('Task History - FIFO Eviction', () => {
|
|
207
|
+
it('should enforce max 20 entries', async () => {
|
|
208
|
+
// Complete 25 tasks
|
|
209
|
+
for (let i = 1; i <= 25; i++) {
|
|
210
|
+
const task = createMockTask({
|
|
211
|
+
description: `Task ${i}`,
|
|
212
|
+
})
|
|
213
|
+
await completeTaskAndGetState(testProjectId, task)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const state = await stateStorage.read(testProjectId)
|
|
217
|
+
|
|
218
|
+
expect(state.taskHistory?.length).toBe(20) // Max 20
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('should drop oldest entries when exceeding limit', async () => {
|
|
222
|
+
// Complete 22 tasks
|
|
223
|
+
for (let i = 1; i <= 22; i++) {
|
|
224
|
+
const task = createMockTask({
|
|
225
|
+
description: `Task ${i}`,
|
|
226
|
+
})
|
|
227
|
+
await completeTaskAndGetState(testProjectId, task)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const state = await stateStorage.read(testProjectId)
|
|
231
|
+
|
|
232
|
+
expect(state.taskHistory?.length).toBe(20)
|
|
233
|
+
expect(state.taskHistory![0].title).toBe('Task 22') // Newest
|
|
234
|
+
expect(state.taskHistory![19].title).toBe('Task 3') // Oldest kept
|
|
235
|
+
// Task 1 and Task 2 should be dropped
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
// =============================================================================
|
|
240
|
+
// Tests: Backward Compatibility
|
|
241
|
+
// =============================================================================
|
|
242
|
+
|
|
243
|
+
describe('Task History - Backward Compatibility', () => {
|
|
244
|
+
it('should initialize taskHistory as empty array when undefined', async () => {
|
|
245
|
+
// Read state before any tasks (should use default)
|
|
246
|
+
const state = await stateStorage.read(testProjectId)
|
|
247
|
+
|
|
248
|
+
expect(state.taskHistory).toBeDefined()
|
|
249
|
+
expect(Array.isArray(state.taskHistory)).toBe(true)
|
|
250
|
+
expect(state.taskHistory?.length).toBe(0)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('should handle missing taskHistory field in existing state', async () => {
|
|
254
|
+
// Manually write state without taskHistory field
|
|
255
|
+
const stateFile = pathManager.getStoragePath(testProjectId, 'state.json')
|
|
256
|
+
|
|
257
|
+
await fs.writeFile(
|
|
258
|
+
stateFile,
|
|
259
|
+
JSON.stringify({
|
|
260
|
+
currentTask: null,
|
|
261
|
+
pausedTasks: [],
|
|
262
|
+
lastUpdated: new Date().toISOString(),
|
|
263
|
+
// No taskHistory field
|
|
264
|
+
})
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
// Complete a task - should work without error
|
|
268
|
+
const task = createMockTask({ description: 'New task' })
|
|
269
|
+
const state = await completeTaskAndGetState(testProjectId, task)
|
|
270
|
+
|
|
271
|
+
expect(state.taskHistory).toBeDefined()
|
|
272
|
+
expect(state.taskHistory?.length).toBe(1)
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
// =============================================================================
|
|
277
|
+
// Tests: Accessor Methods
|
|
278
|
+
// =============================================================================
|
|
279
|
+
|
|
280
|
+
describe('Task History - Accessor Methods', () => {
|
|
281
|
+
beforeEach(async () => {
|
|
282
|
+
// Setup: Complete 3 tasks with different classifications
|
|
283
|
+
const tasks = [
|
|
284
|
+
{ description: 'Feature task 1', type: 'feature' },
|
|
285
|
+
{ description: 'Bug fix', type: 'bug' },
|
|
286
|
+
{ description: 'Feature task 2', type: 'feature' },
|
|
287
|
+
]
|
|
288
|
+
|
|
289
|
+
for (const taskData of tasks) {
|
|
290
|
+
const task = createMockTask(taskData)
|
|
291
|
+
await completeTaskAndGetState(testProjectId, task)
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('getTaskHistory() should return full history', async () => {
|
|
296
|
+
const history = await stateStorage.getTaskHistory(testProjectId)
|
|
297
|
+
|
|
298
|
+
expect(history.length).toBe(3)
|
|
299
|
+
expect(history[0].title).toBe('Feature task 2') // Newest first
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('getMostRecentTask() should return latest entry', async () => {
|
|
303
|
+
const recent = await stateStorage.getMostRecentTask(testProjectId)
|
|
304
|
+
|
|
305
|
+
expect(recent).not.toBeNull()
|
|
306
|
+
expect(recent!.title).toBe('Feature task 2')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('getMostRecentTask() should return null when no history', async () => {
|
|
310
|
+
const emptyProjectId = `empty-${Date.now()}`
|
|
311
|
+
|
|
312
|
+
// Create storage directory for empty project
|
|
313
|
+
const storagePath = pathManager.getStoragePath(emptyProjectId, '')
|
|
314
|
+
await fs.mkdir(storagePath, { recursive: true })
|
|
315
|
+
|
|
316
|
+
const recent = await stateStorage.getMostRecentTask(emptyProjectId)
|
|
317
|
+
|
|
318
|
+
expect(recent).toBeNull()
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('getTaskHistoryByType() should filter by classification', async () => {
|
|
322
|
+
const featureHistory = await stateStorage.getTaskHistoryByType(testProjectId, 'feature')
|
|
323
|
+
|
|
324
|
+
expect(featureHistory.length).toBe(2)
|
|
325
|
+
expect(featureHistory.every((h) => h.classification === 'feature')).toBe(true)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('getTaskHistoryByType() should return empty array for no matches', async () => {
|
|
329
|
+
const choreHistory = await stateStorage.getTaskHistoryByType(testProjectId, 'chore')
|
|
330
|
+
|
|
331
|
+
expect(choreHistory.length).toBe(0)
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
// =============================================================================
|
|
336
|
+
// Tests: Context Injection (Markdown Generation)
|
|
337
|
+
// =============================================================================
|
|
338
|
+
|
|
339
|
+
describe('Task History - Context Injection', () => {
|
|
340
|
+
it('should include task history section in markdown when history exists', async () => {
|
|
341
|
+
// Complete a task
|
|
342
|
+
const task = createMockTask({ description: 'Completed task' })
|
|
343
|
+
await completeTaskAndGetState(testProjectId, task)
|
|
344
|
+
|
|
345
|
+
// Generate markdown
|
|
346
|
+
const state = await stateStorage.read(testProjectId)
|
|
347
|
+
const markdown = (stateStorage as any).toMarkdown(state)
|
|
348
|
+
|
|
349
|
+
expect(markdown).toContain('Recent tasks')
|
|
350
|
+
expect(markdown).toContain('Completed task')
|
|
351
|
+
expect(markdown).toContain('Task history helps identify patterns')
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('should filter by current task classification when task is active', async () => {
|
|
355
|
+
// Complete several tasks
|
|
356
|
+
const tasks = [
|
|
357
|
+
{ description: 'Feature 1', type: 'feature' },
|
|
358
|
+
{ description: 'Bug 1', type: 'bug' },
|
|
359
|
+
{ description: 'Feature 2', type: 'feature' },
|
|
360
|
+
]
|
|
361
|
+
|
|
362
|
+
for (const taskData of tasks) {
|
|
363
|
+
const task = createMockTask(taskData)
|
|
364
|
+
await completeTaskAndGetState(testProjectId, task)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Start a new bug task (should filter history to bugs only)
|
|
368
|
+
const currentTask = createMockTask({ description: 'Current bug', type: 'bug' })
|
|
369
|
+
await stateStorage.startTask(testProjectId, currentTask)
|
|
370
|
+
|
|
371
|
+
const state = await stateStorage.read(testProjectId)
|
|
372
|
+
const markdown = (stateStorage as any).toMarkdown(state)
|
|
373
|
+
|
|
374
|
+
expect(markdown).toContain('Recent bug tasks')
|
|
375
|
+
expect(markdown).toContain('Bug 1')
|
|
376
|
+
expect(markdown).not.toContain('Feature 1')
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('should show max 5 recent tasks when no current task', async () => {
|
|
380
|
+
// Complete 7 tasks
|
|
381
|
+
for (let i = 1; i <= 7; i++) {
|
|
382
|
+
const task = createMockTask({ description: `Task ${i}` })
|
|
383
|
+
await completeTaskAndGetState(testProjectId, task)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const state = await stateStorage.read(testProjectId)
|
|
387
|
+
const markdown = (stateStorage as any).toMarkdown(state)
|
|
388
|
+
|
|
389
|
+
expect(markdown).toContain('Recent tasks (5)')
|
|
390
|
+
expect(markdown).toContain('Task 7')
|
|
391
|
+
expect(markdown).toContain('Task 3')
|
|
392
|
+
expect(markdown).not.toContain('Task 2') // Too old
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('should show max 3 entries of same type when task is active', async () => {
|
|
396
|
+
// Complete 5 feature tasks
|
|
397
|
+
for (let i = 1; i <= 5; i++) {
|
|
398
|
+
const task = createMockTask({ description: `Feature ${i}`, type: 'feature' })
|
|
399
|
+
await completeTaskAndGetState(testProjectId, task)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Start a new feature task
|
|
403
|
+
const currentTask = createMockTask({ description: 'Current feature', type: 'feature' })
|
|
404
|
+
await stateStorage.startTask(testProjectId, currentTask)
|
|
405
|
+
|
|
406
|
+
const state = await stateStorage.read(testProjectId)
|
|
407
|
+
const markdown = (stateStorage as any).toMarkdown(state)
|
|
408
|
+
|
|
409
|
+
expect(markdown).toContain('Recent feature tasks (3)')
|
|
410
|
+
expect(markdown).toContain('Feature 5')
|
|
411
|
+
expect(markdown).toContain('Feature 4')
|
|
412
|
+
expect(markdown).toContain('Feature 3')
|
|
413
|
+
expect(markdown).not.toContain('Feature 2') // Too old (beyond 3)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('should not show history section when taskHistory is empty', async () => {
|
|
417
|
+
const state = await stateStorage.read(testProjectId)
|
|
418
|
+
const markdown = (stateStorage as any).toMarkdown(state)
|
|
419
|
+
|
|
420
|
+
expect(markdown).not.toContain('Recent tasks')
|
|
421
|
+
expect(markdown).not.toContain('Task history helps identify patterns')
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('should include completion time and subtask count', async () => {
|
|
425
|
+
const task = createMockTask({
|
|
426
|
+
description: 'Task with details',
|
|
427
|
+
subtasks: [
|
|
428
|
+
{
|
|
429
|
+
id: 'st-1',
|
|
430
|
+
description: 'Subtask 1',
|
|
431
|
+
domain: 'backend',
|
|
432
|
+
agent: 'backend.md',
|
|
433
|
+
status: 'completed',
|
|
434
|
+
dependsOn: [],
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
id: 'st-2',
|
|
438
|
+
description: 'Subtask 2',
|
|
439
|
+
domain: 'backend',
|
|
440
|
+
agent: 'backend.md',
|
|
441
|
+
status: 'completed',
|
|
442
|
+
dependsOn: [],
|
|
443
|
+
},
|
|
444
|
+
],
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
await completeTaskAndGetState(testProjectId, task)
|
|
448
|
+
|
|
449
|
+
const state = await stateStorage.read(testProjectId)
|
|
450
|
+
const markdown = (stateStorage as any).toMarkdown(state)
|
|
451
|
+
|
|
452
|
+
expect(markdown).toContain('2 subtasks')
|
|
453
|
+
expect(markdown).toContain('Completed:')
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
it('should include Linear ID when present', async () => {
|
|
457
|
+
const task = createMockTask({
|
|
458
|
+
description: 'Task with Linear',
|
|
459
|
+
linearId: 'PRJ-456',
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
await completeTaskAndGetState(testProjectId, task)
|
|
463
|
+
|
|
464
|
+
const state = await stateStorage.read(testProjectId)
|
|
465
|
+
const markdown = (stateStorage as any).toMarkdown(state)
|
|
466
|
+
|
|
467
|
+
expect(markdown).toContain('Linear: PRJ-456')
|
|
468
|
+
})
|
|
469
|
+
})
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { linearService } from '../integrations/linear'
|
|
20
20
|
import outcomeRecorder from '../outcomes/recorder'
|
|
21
21
|
import { generateUUID } from '../schemas'
|
|
22
|
+
import type { TaskFeedback } from '../schemas/state'
|
|
22
23
|
import { queueStorage, stateStorage } from '../storage'
|
|
23
24
|
import type { CommandResult } from '../types'
|
|
24
25
|
import { getErrorMessage } from '../types/fs'
|
|
@@ -174,10 +175,11 @@ export class WorkflowCommands extends PrjctCommandsBase {
|
|
|
174
175
|
|
|
175
176
|
/**
|
|
176
177
|
* /p:done - Complete current task
|
|
178
|
+
* Optionally accepts structured feedback for the task-to-analysis feedback loop (PRJ-272)
|
|
177
179
|
*/
|
|
178
180
|
async done(
|
|
179
181
|
projectPath: string = process.cwd(),
|
|
180
|
-
options: { skipHooks?: boolean } = {}
|
|
182
|
+
options: { skipHooks?: boolean; feedback?: TaskFeedback } = {}
|
|
181
183
|
): Promise<CommandResult> {
|
|
182
184
|
try {
|
|
183
185
|
const initResult = await this.ensureProjectInit(projectPath)
|
|
@@ -249,7 +251,8 @@ export class WorkflowCommands extends PrjctCommandsBase {
|
|
|
249
251
|
}
|
|
250
252
|
|
|
251
253
|
// Write-through: Complete task (JSON → MD → Event)
|
|
252
|
-
|
|
254
|
+
// Pass feedback for the task-to-analysis feedback loop (PRJ-272)
|
|
255
|
+
await stateStorage.completeTask(projectId, options.feedback)
|
|
253
256
|
|
|
254
257
|
// Sync to Linear if task has linearId
|
|
255
258
|
const linearId = (currentTask as { linearId?: string }).linearId
|
package/core/schemas/state.ts
CHANGED
|
@@ -111,10 +111,50 @@ export const PreviousTaskSchema = z.object({
|
|
|
111
111
|
pauseReason: z.string().optional(),
|
|
112
112
|
})
|
|
113
113
|
|
|
114
|
+
// Task feedback captured during completion (PRJ-272)
|
|
115
|
+
// Enables the task-to-analysis feedback loop: tasks report discoveries back to analysis
|
|
116
|
+
export const TaskFeedbackSchema = z.object({
|
|
117
|
+
// Stack confirmations - tech confirmed/used during the task
|
|
118
|
+
stackConfirmed: z.array(z.string()).optional(), // ["React 18", "TypeScript strict mode"]
|
|
119
|
+
// Patterns discovered during the task
|
|
120
|
+
patternsDiscovered: z.array(z.string()).optional(), // ["API routes follow /api/v1/{resource}"]
|
|
121
|
+
// Agent accuracy - how well domain agents performed
|
|
122
|
+
agentAccuracy: z
|
|
123
|
+
.array(
|
|
124
|
+
z.object({
|
|
125
|
+
agent: z.string(), // "backend.md"
|
|
126
|
+
rating: z.enum(['helpful', 'neutral', 'inaccurate']),
|
|
127
|
+
note: z.string().optional(), // "Missing Tailwind context"
|
|
128
|
+
})
|
|
129
|
+
)
|
|
130
|
+
.optional(),
|
|
131
|
+
// Issues encountered during the task
|
|
132
|
+
issuesEncountered: z.array(z.string()).optional(), // ["ESLint conflicts with Prettier"]
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// Task history entry for completed tasks
|
|
136
|
+
// Stores historical context to enable pattern learning and cross-task correlation
|
|
137
|
+
export const TaskHistoryEntrySchema = z.object({
|
|
138
|
+
taskId: z.string(), // task UUID
|
|
139
|
+
title: z.string(), // parent task description
|
|
140
|
+
classification: TaskTypeSchema, // feature, bug, improvement, chore
|
|
141
|
+
startedAt: z.string(), // ISO8601
|
|
142
|
+
completedAt: z.string(), // ISO8601
|
|
143
|
+
subtaskCount: z.number(), // total number of subtasks
|
|
144
|
+
subtaskSummaries: z.array(SubtaskSummarySchema), // summary of each subtask
|
|
145
|
+
outcome: z.string(), // brief description of what was accomplished
|
|
146
|
+
branchName: z.string(), // git branch used
|
|
147
|
+
linearId: z.string().optional(), // Linear issue ID if linked
|
|
148
|
+
linearUuid: z.string().optional(), // Linear internal UUID
|
|
149
|
+
prUrl: z.string().optional(), // PR URL if shipped
|
|
150
|
+
feedback: TaskFeedbackSchema.optional(), // Task-to-analysis feedback (PRJ-272)
|
|
151
|
+
})
|
|
152
|
+
|
|
114
153
|
export const StateJsonSchema = z.object({
|
|
115
154
|
currentTask: CurrentTaskSchema.nullable(),
|
|
116
155
|
previousTask: PreviousTaskSchema.nullable().optional(), // deprecated: use pausedTasks
|
|
117
156
|
pausedTasks: z.array(PreviousTaskSchema).optional(), // replaces previousTask
|
|
157
|
+
taskHistory: z.array(TaskHistoryEntrySchema).optional(), // completed tasks history (max 20)
|
|
118
158
|
lastUpdated: z.string(),
|
|
119
159
|
})
|
|
120
160
|
|
|
@@ -181,6 +221,8 @@ export type SubtaskProgress = z.infer<typeof SubtaskProgressSchema>
|
|
|
181
221
|
|
|
182
222
|
export type CurrentTask = z.infer<typeof CurrentTaskSchema>
|
|
183
223
|
export type PreviousTask = z.infer<typeof PreviousTaskSchema>
|
|
224
|
+
export type TaskFeedback = z.infer<typeof TaskFeedbackSchema>
|
|
225
|
+
export type TaskHistoryEntry = z.infer<typeof TaskHistoryEntrySchema>
|
|
184
226
|
export type StateJson = z.infer<typeof StateJsonSchema>
|
|
185
227
|
export type QueueTask = z.infer<typeof QueueTaskSchema>
|
|
186
228
|
export type QueueJson = z.infer<typeof QueueJsonSchema>
|
|
@@ -224,6 +266,7 @@ export const validateSubtaskCompletion = (
|
|
|
224
266
|
export const DEFAULT_STATE: StateJson = {
|
|
225
267
|
currentTask: null,
|
|
226
268
|
pausedTasks: [],
|
|
269
|
+
taskHistory: [],
|
|
227
270
|
lastUpdated: '',
|
|
228
271
|
}
|
|
229
272
|
|
|
@@ -37,6 +37,13 @@ export interface ProjectStats {
|
|
|
37
37
|
frameworks: string[]
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** Aggregated task feedback for agent generation (PRJ-272) */
|
|
41
|
+
export interface TaskFeedbackContext {
|
|
42
|
+
patternsDiscovered: string[]
|
|
43
|
+
knownGotchas: string[]
|
|
44
|
+
agentAccuracy: Array<{ agent: string; rating: string; note?: string }>
|
|
45
|
+
}
|
|
46
|
+
|
|
40
47
|
// ============================================================================
|
|
41
48
|
// AGENT GENERATOR CLASS
|
|
42
49
|
// ============================================================================
|
|
@@ -50,11 +57,20 @@ export class AgentGenerator {
|
|
|
50
57
|
this.templatesPath = templatesPath || path.join(__dirname, '..', '..', 'templates', 'subagents')
|
|
51
58
|
}
|
|
52
59
|
|
|
60
|
+
/** Task feedback context for agent generation (PRJ-272) */
|
|
61
|
+
private feedbackContext?: TaskFeedbackContext
|
|
62
|
+
|
|
53
63
|
/**
|
|
54
64
|
* Generate all agents based on stack detection
|
|
65
|
+
* Optionally accepts task feedback to influence agent content (PRJ-272)
|
|
55
66
|
*/
|
|
56
|
-
async generate(
|
|
67
|
+
async generate(
|
|
68
|
+
stack: StackDetection,
|
|
69
|
+
stats: ProjectStats,
|
|
70
|
+
feedbackContext?: TaskFeedbackContext
|
|
71
|
+
): Promise<AgentInfo[]> {
|
|
57
72
|
const agents: AgentInfo[] = []
|
|
73
|
+
this.feedbackContext = feedbackContext
|
|
58
74
|
|
|
59
75
|
// Purge old agents
|
|
60
76
|
await this.purgeOldAgents()
|
|
@@ -227,6 +243,7 @@ export class AgentGenerator {
|
|
|
227
243
|
|
|
228
244
|
/**
|
|
229
245
|
* Generate a single domain agent
|
|
246
|
+
* Injects task feedback learnings when available (PRJ-272)
|
|
230
247
|
*/
|
|
231
248
|
private async generateDomainAgent(
|
|
232
249
|
name: string,
|
|
@@ -248,9 +265,61 @@ export class AgentGenerator {
|
|
|
248
265
|
content = this.generateMinimalDomainAgent(name, stats, stack)
|
|
249
266
|
}
|
|
250
267
|
|
|
268
|
+
// Inject task feedback learnings (PRJ-272)
|
|
269
|
+
content = this.injectFeedbackSection(content, name)
|
|
270
|
+
|
|
251
271
|
await this.writeAgentWithPreservation(`${name}.md`, content)
|
|
252
272
|
}
|
|
253
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Inject a "Recent Learnings" section into agent content from task feedback (PRJ-272)
|
|
276
|
+
* Only injects if there are relevant patterns, gotchas, or agent accuracy notes
|
|
277
|
+
*/
|
|
278
|
+
private injectFeedbackSection(content: string, agentName: string): string {
|
|
279
|
+
if (!this.feedbackContext) return content
|
|
280
|
+
|
|
281
|
+
const { patternsDiscovered, knownGotchas, agentAccuracy } = this.feedbackContext
|
|
282
|
+
|
|
283
|
+
// Filter agent accuracy notes relevant to this agent
|
|
284
|
+
const agentNotes = agentAccuracy.filter(
|
|
285
|
+
(a) => a.agent === `${agentName}.md` || a.agent === agentName
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
const hasContent =
|
|
289
|
+
patternsDiscovered.length > 0 || knownGotchas.length > 0 || agentNotes.length > 0
|
|
290
|
+
|
|
291
|
+
if (!hasContent) return content
|
|
292
|
+
|
|
293
|
+
const lines: string[] = ['\n## Recent Learnings (from completed tasks)\n']
|
|
294
|
+
|
|
295
|
+
if (patternsDiscovered.length > 0) {
|
|
296
|
+
lines.push('### Discovered Patterns')
|
|
297
|
+
for (const pattern of patternsDiscovered) {
|
|
298
|
+
lines.push(`- ${pattern}`)
|
|
299
|
+
}
|
|
300
|
+
lines.push('')
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (knownGotchas.length > 0) {
|
|
304
|
+
lines.push('### Known Gotchas')
|
|
305
|
+
for (const gotcha of knownGotchas) {
|
|
306
|
+
lines.push(`- ${gotcha}`)
|
|
307
|
+
}
|
|
308
|
+
lines.push('')
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (agentNotes.length > 0) {
|
|
312
|
+
lines.push('### Agent Accuracy Notes')
|
|
313
|
+
for (const note of agentNotes) {
|
|
314
|
+
const desc = note.note ? ` — ${note.note}` : ''
|
|
315
|
+
lines.push(`- ${note.rating}${desc}`)
|
|
316
|
+
}
|
|
317
|
+
lines.push('')
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return content + lines.join('\n')
|
|
321
|
+
}
|
|
322
|
+
|
|
254
323
|
/**
|
|
255
324
|
* Generate minimal workflow agent content
|
|
256
325
|
*/
|