prjct-cli 1.20.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 +149 -0
- package/README.md +90 -0
- package/core/__tests__/storage/analysis-storage.test.ts +364 -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/analysis.ts +96 -1
- package/core/commands/commands.ts +1 -1
- package/core/commands/workflow.ts +5 -2
- package/core/index.ts +5 -1
- package/core/schemas/analysis.ts +441 -0
- 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/analysis-storage.ts +46 -1
- package/core/storage/state-storage.ts +190 -3
- package/dist/bin/prjct.mjs +1316 -649
- 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
|
+
})
|
|
@@ -922,11 +922,21 @@ export class AnalysisCommands extends PrjctCommandsBase {
|
|
|
922
922
|
|
|
923
923
|
/**
|
|
924
924
|
* prjct verify - Verify integrity of sealed analysis (PRJ-263)
|
|
925
|
+
*
|
|
926
|
+
* Modes:
|
|
927
|
+
* - Default: Cryptographic verification (signature check)
|
|
928
|
+
* - --semantic: Semantic verification (data accuracy check, PRJ-270)
|
|
925
929
|
*/
|
|
926
930
|
async verify(
|
|
927
931
|
projectPath: string = process.cwd(),
|
|
928
|
-
options: { json?: boolean } = {}
|
|
932
|
+
options: { json?: boolean; semantic?: boolean } = {}
|
|
929
933
|
): Promise<CommandResult> {
|
|
934
|
+
// Semantic verification mode (PRJ-270)
|
|
935
|
+
if (options.semantic) {
|
|
936
|
+
return this.semanticVerify(projectPath, options)
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Default: Cryptographic verification (PRJ-263)
|
|
930
940
|
try {
|
|
931
941
|
const initResult = await this.ensureProjectInit(projectPath)
|
|
932
942
|
if (!initResult.success) return initResult
|
|
@@ -958,6 +968,91 @@ export class AnalysisCommands extends PrjctCommandsBase {
|
|
|
958
968
|
}
|
|
959
969
|
}
|
|
960
970
|
|
|
971
|
+
/**
|
|
972
|
+
* prjct analysis verify --semantic - Semantic verification of analysis results (PRJ-270)
|
|
973
|
+
*
|
|
974
|
+
* Validates that analysis data matches actual project state:
|
|
975
|
+
* - Frameworks exist in package.json
|
|
976
|
+
* - Languages match file extensions
|
|
977
|
+
* - Pattern locations reference real files
|
|
978
|
+
* - File count is accurate
|
|
979
|
+
* - Anti-pattern files exist
|
|
980
|
+
*/
|
|
981
|
+
async semanticVerify(
|
|
982
|
+
projectPath: string = process.cwd(),
|
|
983
|
+
options: { json?: boolean } = {}
|
|
984
|
+
): Promise<CommandResult> {
|
|
985
|
+
try {
|
|
986
|
+
const initResult = await this.ensureProjectInit(projectPath)
|
|
987
|
+
if (!initResult.success) return initResult
|
|
988
|
+
|
|
989
|
+
const projectId = await configManager.getProjectId(projectPath)
|
|
990
|
+
if (!projectId) {
|
|
991
|
+
if (options.json) {
|
|
992
|
+
console.log(JSON.stringify({ success: false, error: 'No project ID found' }))
|
|
993
|
+
} else {
|
|
994
|
+
out.fail('No project ID found')
|
|
995
|
+
}
|
|
996
|
+
return { success: false, error: 'No project ID found' }
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Get project path from project.json
|
|
1000
|
+
const globalPath = pathManager.getGlobalProjectPath(projectId)
|
|
1001
|
+
let repoPath = projectPath
|
|
1002
|
+
try {
|
|
1003
|
+
const projectJson = JSON.parse(
|
|
1004
|
+
await fs.readFile(path.join(globalPath, 'project.json'), 'utf-8')
|
|
1005
|
+
)
|
|
1006
|
+
repoPath = projectJson.repoPath || projectPath
|
|
1007
|
+
} catch {
|
|
1008
|
+
// Use fallback projectPath
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Run semantic verification
|
|
1012
|
+
const result = await analysisStorage.semanticVerify(projectId, repoPath)
|
|
1013
|
+
|
|
1014
|
+
// JSON output mode
|
|
1015
|
+
if (options.json) {
|
|
1016
|
+
console.log(JSON.stringify(result))
|
|
1017
|
+
return { success: result.passed, data: result }
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Human-readable output
|
|
1021
|
+
console.log('')
|
|
1022
|
+
if (result.passed) {
|
|
1023
|
+
out.done('Semantic verification passed')
|
|
1024
|
+
console.log(
|
|
1025
|
+
` ${result.passedCount}/${result.checks.length} checks passed (${result.totalMs}ms)`
|
|
1026
|
+
)
|
|
1027
|
+
} else {
|
|
1028
|
+
out.fail('Semantic verification failed')
|
|
1029
|
+
console.log(` ${result.failedCount}/${result.checks.length} checks failed`)
|
|
1030
|
+
}
|
|
1031
|
+
console.log('')
|
|
1032
|
+
|
|
1033
|
+
// Show check details
|
|
1034
|
+
console.log('Check Results:')
|
|
1035
|
+
for (const check of result.checks) {
|
|
1036
|
+
const icon = check.passed ? '✓' : '✗'
|
|
1037
|
+
const status = check.passed
|
|
1038
|
+
? `${check.output} (${check.durationMs}ms)`
|
|
1039
|
+
: check.error || 'Failed'
|
|
1040
|
+
console.log(` ${icon} ${check.name}: ${status}`)
|
|
1041
|
+
}
|
|
1042
|
+
console.log('')
|
|
1043
|
+
|
|
1044
|
+
return { success: result.passed, data: result }
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
const errMsg = getErrorMessage(error)
|
|
1047
|
+
if (options.json) {
|
|
1048
|
+
console.log(JSON.stringify({ success: false, error: errMsg }))
|
|
1049
|
+
} else {
|
|
1050
|
+
out.fail(errMsg)
|
|
1051
|
+
}
|
|
1052
|
+
return { success: false, error: errMsg }
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
961
1056
|
/**
|
|
962
1057
|
* Get session activity stats from today's events
|
|
963
1058
|
* @see PRJ-89
|
|
@@ -240,7 +240,7 @@ class PrjctCommands {
|
|
|
240
240
|
|
|
241
241
|
async verify(
|
|
242
242
|
projectPath: string = process.cwd(),
|
|
243
|
-
options: { json?: boolean } = {}
|
|
243
|
+
options: { json?: boolean; semantic?: boolean } = {}
|
|
244
244
|
): Promise<CommandResult> {
|
|
245
245
|
return this.analysis.verify(projectPath, options)
|
|
246
246
|
}
|
|
@@ -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/index.ts
CHANGED
|
@@ -173,7 +173,11 @@ async function main(): Promise<void> {
|
|
|
173
173
|
full: options.full === true,
|
|
174
174
|
}),
|
|
175
175
|
seal: () => commands.seal(process.cwd(), { json: options.json === true }),
|
|
176
|
-
verify: () =>
|
|
176
|
+
verify: () =>
|
|
177
|
+
commands.verify(process.cwd(), {
|
|
178
|
+
json: options.json === true,
|
|
179
|
+
semantic: options.semantic === true,
|
|
180
|
+
}),
|
|
177
181
|
start: () => commands.start(),
|
|
178
182
|
// Context (for Claude templates)
|
|
179
183
|
context: (p) => commands.context(p),
|