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.
@@ -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),
@@ -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 type { ModelMetadata } from './model'
8
+ import { z } from 'zod'
9
+ import { ModelMetadataSchema } from './model'
8
10
 
9
- export interface CodePattern {
10
- name: string
11
- description: string
12
- location?: string
13
- }
11
+ // =============================================================================
12
+ // Zod Schemas - Source of Truth
13
+ // =============================================================================
14
14
 
15
- export interface AntiPattern {
16
- issue: string
17
- file: string
18
- suggestion: string
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 interface AnalysisSchema {
22
- projectId: string
23
- languages: string[]
24
- frameworks: string[]
25
- packageManager?: string
26
- sourceDir?: string
27
- testDir?: string
28
- configFiles: string[]
29
- fileCount: number
30
- patterns: CodePattern[]
31
- antiPatterns: AntiPattern[]
32
- analyzedAt: string // ISO8601
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?: 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
  }
@@ -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().optional(),
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 }
@@ -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,