prjct-cli 1.10.0 → 1.12.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 +76 -2344
- package/core/__tests__/storage/analysis-storage.test.ts +277 -0
- package/core/__tests__/storage/subtask-handoff.test.ts +237 -0
- package/core/agentic/prompt-builder.ts +18 -0
- package/core/commands/analysis.ts +117 -2
- package/core/commands/command-data.ts +29 -0
- package/core/commands/commands.ts +14 -0
- package/core/commands/register.ts +2 -0
- package/core/index.ts +2 -0
- package/core/schemas/analysis.ts +69 -25
- package/core/schemas/state.ts +22 -2
- package/core/services/sync-service.ts +35 -0
- package/core/storage/analysis-storage.ts +328 -0
- package/core/storage/index.ts +2 -0
- package/core/storage/state-storage.ts +40 -8
- package/core/types/agentic.ts +7 -0
- package/dist/bin/prjct.mjs +1012 -514
- package/package.json +1 -1
- package/templates/commands/done.md +86 -18
|
@@ -218,6 +218,35 @@ export const COMMANDS: CommandMeta[] = [
|
|
|
218
218
|
'Configurable staleness thresholds',
|
|
219
219
|
],
|
|
220
220
|
},
|
|
221
|
+
{
|
|
222
|
+
name: 'seal',
|
|
223
|
+
group: 'core',
|
|
224
|
+
description: 'Seal the current draft analysis with a commit-hash signature',
|
|
225
|
+
usage: { claude: '/p:seal', terminal: 'prjct seal' },
|
|
226
|
+
params: '[--json]',
|
|
227
|
+
implemented: true,
|
|
228
|
+
hasTemplate: false,
|
|
229
|
+
requiresProject: true,
|
|
230
|
+
features: [
|
|
231
|
+
'Locks draft analysis with SHA-256 signature',
|
|
232
|
+
'Only sealed analysis feeds task context',
|
|
233
|
+
'Detects staleness when HEAD moves past sealed commit',
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
name: 'verify',
|
|
238
|
+
group: 'core',
|
|
239
|
+
description: 'Verify integrity of sealed analysis',
|
|
240
|
+
usage: { claude: '/p:verify', terminal: 'prjct verify' },
|
|
241
|
+
params: '[--json]',
|
|
242
|
+
implemented: true,
|
|
243
|
+
hasTemplate: false,
|
|
244
|
+
requiresProject: true,
|
|
245
|
+
features: [
|
|
246
|
+
'Recomputes SHA-256 signature and compares',
|
|
247
|
+
'Detects if sealed analysis was modified',
|
|
248
|
+
],
|
|
249
|
+
},
|
|
221
250
|
{
|
|
222
251
|
name: 'help',
|
|
223
252
|
group: 'core',
|
|
@@ -218,6 +218,20 @@ class PrjctCommands {
|
|
|
218
218
|
return this.analysis.status(projectPath, options)
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
async seal(
|
|
222
|
+
projectPath: string = process.cwd(),
|
|
223
|
+
options: { json?: boolean } = {}
|
|
224
|
+
): Promise<CommandResult> {
|
|
225
|
+
return this.analysis.seal(projectPath, options)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async verify(
|
|
229
|
+
projectPath: string = process.cwd(),
|
|
230
|
+
options: { json?: boolean } = {}
|
|
231
|
+
): Promise<CommandResult> {
|
|
232
|
+
return this.analysis.verify(projectPath, options)
|
|
233
|
+
}
|
|
234
|
+
|
|
221
235
|
// ========== Context Commands ==========
|
|
222
236
|
|
|
223
237
|
async context(
|
|
@@ -91,6 +91,8 @@ export function registerAllCommands(): void {
|
|
|
91
91
|
commandRegistry.registerMethod('sync', analysis, 'sync', getMeta('sync'))
|
|
92
92
|
commandRegistry.registerMethod('stats', analysis, 'stats', getMeta('stats'))
|
|
93
93
|
commandRegistry.registerMethod('status', analysis, 'status', getMeta('status'))
|
|
94
|
+
commandRegistry.registerMethod('seal', analysis, 'seal', getMeta('seal'))
|
|
95
|
+
commandRegistry.registerMethod('verify', analysis, 'verify', getMeta('verify'))
|
|
94
96
|
|
|
95
97
|
// Setup commands
|
|
96
98
|
commandRegistry.registerMethod('start', setup, 'start', getMeta('start'))
|
package/core/index.ts
CHANGED
|
@@ -170,6 +170,8 @@ async function main(): Promise<void> {
|
|
|
170
170
|
json: options.json === true,
|
|
171
171
|
package: options.package ? String(options.package) : undefined,
|
|
172
172
|
}),
|
|
173
|
+
seal: () => commands.seal(process.cwd(), { json: options.json === true }),
|
|
174
|
+
verify: () => commands.verify(process.cwd(), { json: options.json === true }),
|
|
173
175
|
start: () => commands.start(),
|
|
174
176
|
// Context (for Claude templates)
|
|
175
177
|
context: (p) => commands.context(p),
|
package/core/schemas/analysis.ts
CHANGED
|
@@ -2,37 +2,80 @@
|
|
|
2
2
|
* Analysis Schema
|
|
3
3
|
*
|
|
4
4
|
* Defines the structure for analysis.json - repository analysis.
|
|
5
|
+
* Supports a 3-state lifecycle: DRAFT → VERIFIED → SEALED (PRJ-263).
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
|
-
import
|
|
8
|
+
import { z } from 'zod'
|
|
9
|
+
import { ModelMetadataSchema } from './model'
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
location?: string
|
|
13
|
-
}
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// Zod Schemas - Source of Truth
|
|
13
|
+
// =============================================================================
|
|
14
14
|
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
export const AnalysisStatusSchema = z.enum(['draft', 'verified', 'sealed'])
|
|
16
|
+
|
|
17
|
+
export const CodePatternSchema = z.object({
|
|
18
|
+
name: z.string(),
|
|
19
|
+
description: z.string(),
|
|
20
|
+
location: z.string().optional(),
|
|
21
|
+
})
|
|
20
22
|
|
|
21
|
-
export
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
23
|
+
export const AntiPatternSchema = z.object({
|
|
24
|
+
issue: z.string(),
|
|
25
|
+
file: z.string(),
|
|
26
|
+
suggestion: z.string(),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export const AnalysisItemSchema = z.object({
|
|
30
|
+
projectId: z.string(),
|
|
31
|
+
languages: z.array(z.string()),
|
|
32
|
+
frameworks: z.array(z.string()),
|
|
33
|
+
packageManager: z.string().optional(),
|
|
34
|
+
sourceDir: z.string().optional(),
|
|
35
|
+
testDir: z.string().optional(),
|
|
36
|
+
configFiles: z.array(z.string()),
|
|
37
|
+
fileCount: z.number(),
|
|
38
|
+
patterns: z.array(CodePatternSchema),
|
|
39
|
+
antiPatterns: z.array(AntiPatternSchema),
|
|
40
|
+
analyzedAt: z.string(), // ISO8601
|
|
33
41
|
/** Which AI model was used for this analysis (PRJ-265) */
|
|
34
|
-
modelMetadata
|
|
35
|
-
|
|
42
|
+
modelMetadata: ModelMetadataSchema.optional(),
|
|
43
|
+
|
|
44
|
+
// Sealable analysis fields (PRJ-263)
|
|
45
|
+
/** Lifecycle status: draft (regenerable), verified (confirmed correct), sealed (locked) */
|
|
46
|
+
status: AnalysisStatusSchema.default('draft'),
|
|
47
|
+
/** Git commit hash at the time of analysis */
|
|
48
|
+
commitHash: z.string().optional(),
|
|
49
|
+
/** SHA-256 signature of analysis content + commit hash */
|
|
50
|
+
signature: z.string().optional(),
|
|
51
|
+
/** When the analysis was sealed */
|
|
52
|
+
sealedAt: z.string().optional(), // ISO8601
|
|
53
|
+
/** When the analysis was verified */
|
|
54
|
+
verifiedAt: z.string().optional(), // ISO8601
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// =============================================================================
|
|
58
|
+
// Inferred Types - Backward Compatible
|
|
59
|
+
// =============================================================================
|
|
60
|
+
|
|
61
|
+
export type AnalysisStatus = z.infer<typeof AnalysisStatusSchema>
|
|
62
|
+
export type CodePattern = z.infer<typeof CodePatternSchema>
|
|
63
|
+
export type AntiPattern = z.infer<typeof AntiPatternSchema>
|
|
64
|
+
/** Use z.input so optional fields with defaults (like status) remain optional in creation */
|
|
65
|
+
export type AnalysisSchema = z.input<typeof AnalysisItemSchema>
|
|
66
|
+
|
|
67
|
+
// =============================================================================
|
|
68
|
+
// Validation Helpers
|
|
69
|
+
// =============================================================================
|
|
70
|
+
|
|
71
|
+
/** Parse and validate analysis.json content */
|
|
72
|
+
export const parseAnalysis = (data: unknown): z.infer<typeof AnalysisItemSchema> =>
|
|
73
|
+
AnalysisItemSchema.parse(data)
|
|
74
|
+
export const safeParseAnalysis = (data: unknown) => AnalysisItemSchema.safeParse(data)
|
|
75
|
+
|
|
76
|
+
// =============================================================================
|
|
77
|
+
// Defaults
|
|
78
|
+
// =============================================================================
|
|
36
79
|
|
|
37
80
|
export const DEFAULT_ANALYSIS: Omit<AnalysisSchema, 'projectId'> = {
|
|
38
81
|
languages: [],
|
|
@@ -42,4 +85,5 @@ export const DEFAULT_ANALYSIS: Omit<AnalysisSchema, 'projectId'> = {
|
|
|
42
85
|
patterns: [],
|
|
43
86
|
antiPatterns: [],
|
|
44
87
|
analyzedAt: new Date().toISOString(),
|
|
88
|
+
status: 'draft',
|
|
45
89
|
}
|
package/core/schemas/state.ts
CHANGED
|
@@ -44,11 +44,18 @@ export const SubtaskSummarySchema = z.object({
|
|
|
44
44
|
action: z.enum(['created', 'modified', 'deleted']),
|
|
45
45
|
})
|
|
46
46
|
),
|
|
47
|
-
whatWasDone: z.array(z.string()),
|
|
48
|
-
outputForNextAgent: z.string().
|
|
47
|
+
whatWasDone: z.array(z.string()).min(1),
|
|
48
|
+
outputForNextAgent: z.string().min(1),
|
|
49
49
|
notes: z.string().optional(),
|
|
50
50
|
})
|
|
51
51
|
|
|
52
|
+
// Schema for validating completion data before persisting
|
|
53
|
+
// Used by completeSubtask() to enforce mandatory handoff
|
|
54
|
+
export const SubtaskCompletionDataSchema = z.object({
|
|
55
|
+
output: z.string().min(1, 'Subtask output is required'),
|
|
56
|
+
summary: SubtaskSummarySchema,
|
|
57
|
+
})
|
|
58
|
+
|
|
52
59
|
// Subtask schema for task fragmentation
|
|
53
60
|
export const SubtaskSchema = z.object({
|
|
54
61
|
id: z.string(), // subtask-xxx
|
|
@@ -169,6 +176,7 @@ export type ActivityType = z.infer<typeof ActivityTypeSchema>
|
|
|
169
176
|
|
|
170
177
|
export type Subtask = z.infer<typeof SubtaskSchema>
|
|
171
178
|
export type SubtaskSummary = z.infer<typeof SubtaskSummarySchema>
|
|
179
|
+
export type SubtaskCompletionData = z.infer<typeof SubtaskCompletionDataSchema>
|
|
172
180
|
export type SubtaskProgress = z.infer<typeof SubtaskProgressSchema>
|
|
173
181
|
|
|
174
182
|
export type CurrentTask = z.infer<typeof CurrentTaskSchema>
|
|
@@ -197,6 +205,18 @@ export const parseQueue = (data: unknown): QueueJson => QueueJsonSchema.parse(da
|
|
|
197
205
|
export const safeParseState = (data: unknown) => StateJsonSchema.safeParse(data)
|
|
198
206
|
export const safeParseQueue = (data: unknown) => QueueJsonSchema.safeParse(data)
|
|
199
207
|
|
|
208
|
+
/** Validate subtask completion data — returns errors or null */
|
|
209
|
+
export const validateSubtaskCompletion = (
|
|
210
|
+
data: unknown
|
|
211
|
+
): { success: true; data: SubtaskCompletionData } | { success: false; errors: string[] } => {
|
|
212
|
+
const result = SubtaskCompletionDataSchema.safeParse(data)
|
|
213
|
+
if (result.success) return { success: true, data: result.data }
|
|
214
|
+
return {
|
|
215
|
+
success: false,
|
|
216
|
+
errors: result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`),
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
200
220
|
// =============================================================================
|
|
201
221
|
// Defaults
|
|
202
222
|
// =============================================================================
|
|
@@ -31,6 +31,7 @@ import { getErrorMessage } from '../errors'
|
|
|
31
31
|
import commandInstaller from '../infrastructure/command-installer'
|
|
32
32
|
import configManager from '../infrastructure/config-manager'
|
|
33
33
|
import pathManager from '../infrastructure/path-manager'
|
|
34
|
+
import { analysisStorage } from '../storage/analysis-storage'
|
|
34
35
|
import { metricsStorage } from '../storage/metrics-storage'
|
|
35
36
|
import type {
|
|
36
37
|
GitData,
|
|
@@ -176,6 +177,7 @@ class SyncService {
|
|
|
176
177
|
this.updateProjectJson(git, stats),
|
|
177
178
|
this.updateStateJson(stats, stack),
|
|
178
179
|
this.logToMemory(git, stats),
|
|
180
|
+
this.saveDraftAnalysis(git, stats, stack),
|
|
179
181
|
])
|
|
180
182
|
|
|
181
183
|
// 9. Record metrics for value dashboard
|
|
@@ -1102,6 +1104,39 @@ You are the ${name} expert for this project. Apply best practices for the detect
|
|
|
1102
1104
|
}
|
|
1103
1105
|
}
|
|
1104
1106
|
|
|
1107
|
+
// ==========================================================================
|
|
1108
|
+
// DRAFT ANALYSIS (PRJ-263)
|
|
1109
|
+
// ==========================================================================
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Save sync results as a draft analysis.
|
|
1113
|
+
* Preserves existing sealed analysis — only the draft is overwritten.
|
|
1114
|
+
*/
|
|
1115
|
+
private async saveDraftAnalysis(
|
|
1116
|
+
git: GitData,
|
|
1117
|
+
stats: ProjectStats,
|
|
1118
|
+
_stack: StackDetection
|
|
1119
|
+
): Promise<void> {
|
|
1120
|
+
try {
|
|
1121
|
+
const commitHash = git.recentCommits[0]?.hash || null
|
|
1122
|
+
|
|
1123
|
+
await analysisStorage.saveDraft(this.projectId!, {
|
|
1124
|
+
projectId: this.projectId!,
|
|
1125
|
+
languages: stats.languages,
|
|
1126
|
+
frameworks: stats.frameworks,
|
|
1127
|
+
configFiles: [],
|
|
1128
|
+
fileCount: stats.fileCount,
|
|
1129
|
+
patterns: [],
|
|
1130
|
+
antiPatterns: [],
|
|
1131
|
+
analyzedAt: dateHelper.getTimestamp(),
|
|
1132
|
+
status: 'draft',
|
|
1133
|
+
commitHash: commitHash ?? undefined,
|
|
1134
|
+
})
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
log.debug('Failed to save draft analysis (non-critical)', { error: getErrorMessage(error) })
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1105
1140
|
// ==========================================================================
|
|
1106
1141
|
// HELPERS
|
|
1107
1142
|
// ==========================================================================
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analysis Storage (PRJ-263)
|
|
3
|
+
*
|
|
4
|
+
* Manages sealable analysis with dual storage:
|
|
5
|
+
* - storage/analysis.json (current draft)
|
|
6
|
+
* - storage/analysis-sealed.json (locked sealed version)
|
|
7
|
+
*
|
|
8
|
+
* Lifecycle: DRAFT → VERIFIED → SEALED
|
|
9
|
+
* Re-sync creates a new draft WITHOUT destroying the sealed version.
|
|
10
|
+
* Only sealed analysis feeds task context.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
import type { AnalysisSchema } from '../schemas/analysis'
|
|
15
|
+
import { AnalysisItemSchema } from '../schemas/analysis'
|
|
16
|
+
import { getTimestamp } from '../utils/date-helper'
|
|
17
|
+
import { StorageManager } from './storage-manager'
|
|
18
|
+
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// Types
|
|
21
|
+
// =============================================================================
|
|
22
|
+
|
|
23
|
+
interface AnalysisStoreData {
|
|
24
|
+
draft: AnalysisSchema | null
|
|
25
|
+
sealed: AnalysisSchema | null
|
|
26
|
+
lastUpdated: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface SealResult {
|
|
30
|
+
success: boolean
|
|
31
|
+
signature?: string
|
|
32
|
+
error?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface StalenessCheck {
|
|
36
|
+
isStale: boolean
|
|
37
|
+
sealedCommit: string | null
|
|
38
|
+
currentCommit: string | null
|
|
39
|
+
message: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// =============================================================================
|
|
43
|
+
// Analysis Storage
|
|
44
|
+
// =============================================================================
|
|
45
|
+
|
|
46
|
+
class AnalysisStorage extends StorageManager<AnalysisStoreData> {
|
|
47
|
+
constructor() {
|
|
48
|
+
super('analysis.json')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
protected getDefault(): AnalysisStoreData {
|
|
52
|
+
return {
|
|
53
|
+
draft: null,
|
|
54
|
+
sealed: null,
|
|
55
|
+
lastUpdated: '',
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
protected getMdFilename(): string {
|
|
60
|
+
return 'analysis.md'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
protected getLayer(): string {
|
|
64
|
+
return 'analysis'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
protected getEventType(action: 'update' | 'create' | 'delete'): string {
|
|
68
|
+
return `analysis.${action}d`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
protected toMarkdown(data: AnalysisStoreData): string {
|
|
72
|
+
const lines = ['# Analysis Status', '']
|
|
73
|
+
|
|
74
|
+
// Show sealed analysis if available
|
|
75
|
+
if (data.sealed) {
|
|
76
|
+
lines.push('## Sealed Analysis')
|
|
77
|
+
lines.push(`- **Status**: sealed`)
|
|
78
|
+
lines.push(`- **Commit**: \`${data.sealed.commitHash || 'unknown'}\``)
|
|
79
|
+
lines.push(`- **Sealed at**: ${data.sealed.sealedAt || 'unknown'}`)
|
|
80
|
+
lines.push(`- **Languages**: ${data.sealed.languages.join(', ') || 'none'}`)
|
|
81
|
+
lines.push(`- **Frameworks**: ${data.sealed.frameworks.join(', ') || 'none'}`)
|
|
82
|
+
lines.push(`- **Files**: ${data.sealed.fileCount}`)
|
|
83
|
+
if (data.sealed.patterns.length > 0) {
|
|
84
|
+
lines.push(`- **Patterns**: ${data.sealed.patterns.map((p) => p.name).join(', ')}`)
|
|
85
|
+
}
|
|
86
|
+
lines.push('')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Show draft if different from sealed
|
|
90
|
+
if (data.draft && data.draft.status === 'draft') {
|
|
91
|
+
lines.push('## Draft Analysis')
|
|
92
|
+
lines.push(`- **Status**: draft (not yet sealed)`)
|
|
93
|
+
lines.push(`- **Commit**: \`${data.draft.commitHash || 'unknown'}\``)
|
|
94
|
+
lines.push(`- **Analyzed at**: ${data.draft.analyzedAt}`)
|
|
95
|
+
lines.push(`- **Languages**: ${data.draft.languages.join(', ') || 'none'}`)
|
|
96
|
+
lines.push(`- **Frameworks**: ${data.draft.frameworks.join(', ') || 'none'}`)
|
|
97
|
+
lines.push(`- **Files**: ${data.draft.fileCount}`)
|
|
98
|
+
lines.push('')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!data.sealed && !data.draft) {
|
|
102
|
+
lines.push('_No analysis available. Run `p. sync` to generate._')
|
|
103
|
+
lines.push('')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return lines.join('\n')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ===========================================================================
|
|
110
|
+
// Domain Methods
|
|
111
|
+
// ===========================================================================
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Save a new draft analysis (called by sync-service).
|
|
115
|
+
* Preserves existing sealed analysis.
|
|
116
|
+
*/
|
|
117
|
+
async saveDraft(projectId: string, analysis: AnalysisSchema): Promise<void> {
|
|
118
|
+
const draft: AnalysisSchema = {
|
|
119
|
+
...analysis,
|
|
120
|
+
status: 'draft',
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Validate with Zod
|
|
124
|
+
AnalysisItemSchema.parse(draft)
|
|
125
|
+
|
|
126
|
+
await this.update(projectId, (data) => ({
|
|
127
|
+
...data,
|
|
128
|
+
draft,
|
|
129
|
+
lastUpdated: getTimestamp(),
|
|
130
|
+
}))
|
|
131
|
+
|
|
132
|
+
await this.publishEntityEvent(projectId, 'analysis', 'drafted', {
|
|
133
|
+
commitHash: draft.commitHash,
|
|
134
|
+
fileCount: draft.fileCount,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Seal the current draft analysis.
|
|
140
|
+
* Computes SHA-256 signature and locks the analysis.
|
|
141
|
+
*/
|
|
142
|
+
async seal(projectId: string): Promise<SealResult> {
|
|
143
|
+
const data = await this.read(projectId)
|
|
144
|
+
|
|
145
|
+
if (!data.draft) {
|
|
146
|
+
return { success: false, error: 'No draft analysis to seal. Run `p. sync` first.' }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (data.draft.status === 'sealed') {
|
|
150
|
+
return { success: false, error: 'Draft is already sealed.' }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Compute signature
|
|
154
|
+
const signature = this.computeSignature(data.draft)
|
|
155
|
+
const now = getTimestamp()
|
|
156
|
+
|
|
157
|
+
const sealed: AnalysisSchema = {
|
|
158
|
+
...data.draft,
|
|
159
|
+
status: 'sealed',
|
|
160
|
+
signature,
|
|
161
|
+
sealedAt: now,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Validate
|
|
165
|
+
AnalysisItemSchema.parse(sealed)
|
|
166
|
+
|
|
167
|
+
await this.write(projectId, {
|
|
168
|
+
draft: null, // Clear draft — it's now sealed
|
|
169
|
+
sealed,
|
|
170
|
+
lastUpdated: now,
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
await this.publishEntityEvent(projectId, 'analysis', 'sealed', {
|
|
174
|
+
commitHash: sealed.commitHash,
|
|
175
|
+
signature,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
return { success: true, signature }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get the sealed analysis (for task context injection).
|
|
183
|
+
* Returns null if no sealed analysis exists.
|
|
184
|
+
*/
|
|
185
|
+
async getSealed(projectId: string): Promise<AnalysisSchema | null> {
|
|
186
|
+
const data = await this.read(projectId)
|
|
187
|
+
return data.sealed
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get the current draft analysis.
|
|
192
|
+
*/
|
|
193
|
+
async getDraft(projectId: string): Promise<AnalysisSchema | null> {
|
|
194
|
+
const data = await this.read(projectId)
|
|
195
|
+
return data.draft
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Get the active analysis (sealed if available, otherwise draft).
|
|
200
|
+
* This is what tasks should consume.
|
|
201
|
+
*/
|
|
202
|
+
async getActive(projectId: string): Promise<AnalysisSchema | null> {
|
|
203
|
+
const data = await this.read(projectId)
|
|
204
|
+
return data.sealed ?? data.draft
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Get the current analysis status.
|
|
209
|
+
*/
|
|
210
|
+
async getStatus(projectId: string): Promise<{
|
|
211
|
+
hasSealed: boolean
|
|
212
|
+
hasDraft: boolean
|
|
213
|
+
sealedCommit: string | null
|
|
214
|
+
draftCommit: string | null
|
|
215
|
+
sealedAt: string | null
|
|
216
|
+
}> {
|
|
217
|
+
const data = await this.read(projectId)
|
|
218
|
+
return {
|
|
219
|
+
hasSealed: data.sealed !== null,
|
|
220
|
+
hasDraft: data.draft !== null,
|
|
221
|
+
sealedCommit: data.sealed?.commitHash ?? null,
|
|
222
|
+
draftCommit: data.draft?.commitHash ?? null,
|
|
223
|
+
sealedAt: data.sealed?.sealedAt ?? null,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Check if sealed analysis is stale (commit hash differs from current HEAD).
|
|
229
|
+
*/
|
|
230
|
+
checkStaleness(sealedCommit: string | null, currentCommit: string | null): StalenessCheck {
|
|
231
|
+
if (!sealedCommit) {
|
|
232
|
+
return {
|
|
233
|
+
isStale: false,
|
|
234
|
+
sealedCommit: null,
|
|
235
|
+
currentCommit,
|
|
236
|
+
message: 'No sealed analysis. Run `p. sync` then `p. seal`.',
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!currentCommit) {
|
|
241
|
+
return {
|
|
242
|
+
isStale: true,
|
|
243
|
+
sealedCommit,
|
|
244
|
+
currentCommit: null,
|
|
245
|
+
message: 'Cannot determine current commit. Analysis may be stale.',
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (sealedCommit !== currentCommit) {
|
|
250
|
+
return {
|
|
251
|
+
isStale: true,
|
|
252
|
+
sealedCommit,
|
|
253
|
+
currentCommit,
|
|
254
|
+
message: `Analysis is stale: sealed at ${sealedCommit}, HEAD is ${currentCommit}. Run \`p. sync\` + \`p. seal\` to update.`,
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
isStale: false,
|
|
260
|
+
sealedCommit,
|
|
261
|
+
currentCommit,
|
|
262
|
+
message: 'Analysis is current.',
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Verify the integrity of a sealed analysis by recomputing its signature.
|
|
268
|
+
*/
|
|
269
|
+
async verify(projectId: string): Promise<{ valid: boolean; message: string }> {
|
|
270
|
+
const data = await this.read(projectId)
|
|
271
|
+
|
|
272
|
+
if (!data.sealed) {
|
|
273
|
+
return { valid: false, message: 'No sealed analysis to verify.' }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!data.sealed.signature) {
|
|
277
|
+
return { valid: false, message: 'Sealed analysis has no signature.' }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const expected = this.computeSignature({
|
|
281
|
+
...data.sealed,
|
|
282
|
+
// Strip signature and sealedAt before recomputing — they weren't part of the original hash
|
|
283
|
+
signature: undefined,
|
|
284
|
+
sealedAt: undefined,
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
if (expected === data.sealed.signature) {
|
|
288
|
+
return { valid: true, message: 'Signature verified. Analysis integrity confirmed.' }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
valid: false,
|
|
293
|
+
message: `Signature mismatch. Expected ${expected}, got ${data.sealed.signature}. Analysis may have been modified.`,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ===========================================================================
|
|
298
|
+
// Private Helpers
|
|
299
|
+
// ===========================================================================
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Compute SHA-256 signature for analysis data.
|
|
303
|
+
* Deterministic: same input always produces same hash.
|
|
304
|
+
*/
|
|
305
|
+
private computeSignature(analysis: AnalysisSchema): string {
|
|
306
|
+
// Build a canonical representation (exclude volatile fields)
|
|
307
|
+
const canonical = {
|
|
308
|
+
projectId: analysis.projectId,
|
|
309
|
+
languages: analysis.languages,
|
|
310
|
+
frameworks: analysis.frameworks,
|
|
311
|
+
packageManager: analysis.packageManager,
|
|
312
|
+
sourceDir: analysis.sourceDir,
|
|
313
|
+
testDir: analysis.testDir,
|
|
314
|
+
configFiles: analysis.configFiles,
|
|
315
|
+
fileCount: analysis.fileCount,
|
|
316
|
+
patterns: analysis.patterns,
|
|
317
|
+
antiPatterns: analysis.antiPatterns,
|
|
318
|
+
analyzedAt: analysis.analyzedAt,
|
|
319
|
+
commitHash: analysis.commitHash,
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex')
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export const analysisStorage = new AnalysisStorage()
|
|
327
|
+
export default analysisStorage
|
|
328
|
+
export type { AnalysisStoreData, SealResult, StalenessCheck }
|
package/core/storage/index.ts
CHANGED
|
@@ -55,6 +55,8 @@ export type {
|
|
|
55
55
|
ShippedJson,
|
|
56
56
|
Storage,
|
|
57
57
|
} from '../types'
|
|
58
|
+
export type { AnalysisStoreData, SealResult, StalenessCheck } from './analysis-storage'
|
|
59
|
+
export { analysisStorage } from './analysis-storage'
|
|
58
60
|
export { ideasStorage } from './ideas-storage'
|
|
59
61
|
export type {
|
|
60
62
|
CategoriesCache,
|