prjct-cli 0.50.0 → 0.52.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.
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Diff Generator for Sync Preview
3
+ *
4
+ * Generates human-readable diffs between old and new context files.
5
+ * Shows what will change before sync applies.
6
+ *
7
+ * @see PRJ-125
8
+ * @module services/diff-generator
9
+ */
10
+
11
+ import chalk from 'chalk'
12
+
13
+ // =============================================================================
14
+ // Types
15
+ // =============================================================================
16
+
17
+ export interface DiffSection {
18
+ name: string
19
+ type: 'added' | 'modified' | 'removed' | 'unchanged'
20
+ before?: string
21
+ after?: string
22
+ lineCount: number
23
+ }
24
+
25
+ export interface PreservedInfo {
26
+ name: string
27
+ lineCount: number
28
+ }
29
+
30
+ export interface SyncDiff {
31
+ hasChanges: boolean
32
+ added: DiffSection[]
33
+ modified: DiffSection[]
34
+ removed: DiffSection[]
35
+ preserved: PreservedInfo[]
36
+ tokensBefore: number
37
+ tokensAfter: number
38
+ tokenDelta: number
39
+ }
40
+
41
+ export interface DiffOptions {
42
+ showFullDiff?: boolean
43
+ colorize?: boolean
44
+ }
45
+
46
+ // =============================================================================
47
+ // Constants
48
+ // =============================================================================
49
+
50
+ const CHARS_PER_TOKEN = 4
51
+
52
+ // =============================================================================
53
+ // Token Estimation
54
+ // =============================================================================
55
+
56
+ /**
57
+ * Estimate token count for content
58
+ */
59
+ export function estimateTokens(content: string): number {
60
+ return Math.ceil(content.length / CHARS_PER_TOKEN)
61
+ }
62
+
63
+ // =============================================================================
64
+ // Section Parsing
65
+ // =============================================================================
66
+
67
+ interface ParsedSection {
68
+ name: string
69
+ content: string
70
+ startLine: number
71
+ endLine: number
72
+ }
73
+
74
+ /**
75
+ * Parse markdown into sections based on headers
76
+ */
77
+ export function parseMarkdownSections(content: string): ParsedSection[] {
78
+ const lines = content.split('\n')
79
+ const sections: ParsedSection[] = []
80
+ let currentSection: ParsedSection | null = null
81
+
82
+ for (let i = 0; i < lines.length; i++) {
83
+ const line = lines[i]
84
+ const headerMatch = line.match(/^(#{1,3})\s+(.+)$/)
85
+
86
+ if (headerMatch) {
87
+ // Save previous section
88
+ if (currentSection) {
89
+ currentSection.endLine = i - 1
90
+ sections.push(currentSection)
91
+ }
92
+
93
+ // Start new section
94
+ currentSection = {
95
+ name: headerMatch[2].trim(),
96
+ content: line,
97
+ startLine: i,
98
+ endLine: i,
99
+ }
100
+ } else if (currentSection) {
101
+ currentSection.content += `\n${line}`
102
+ }
103
+ }
104
+
105
+ // Save last section
106
+ if (currentSection) {
107
+ currentSection.endLine = lines.length - 1
108
+ sections.push(currentSection)
109
+ }
110
+
111
+ return sections
112
+ }
113
+
114
+ /**
115
+ * Check if content is within a preserve block
116
+ */
117
+ function isPreservedSection(content: string): boolean {
118
+ return content.includes('<!-- prjct:preserve')
119
+ }
120
+
121
+ // =============================================================================
122
+ // Diff Generation
123
+ // =============================================================================
124
+
125
+ /**
126
+ * Generate diff between old and new content
127
+ */
128
+ export function generateSyncDiff(oldContent: string, newContent: string): SyncDiff {
129
+ const oldSections = parseMarkdownSections(oldContent)
130
+ const newSections = parseMarkdownSections(newContent)
131
+
132
+ const diff: SyncDiff = {
133
+ hasChanges: false,
134
+ added: [],
135
+ modified: [],
136
+ removed: [],
137
+ preserved: [],
138
+ tokensBefore: estimateTokens(oldContent),
139
+ tokensAfter: estimateTokens(newContent),
140
+ tokenDelta: 0,
141
+ }
142
+
143
+ diff.tokenDelta = diff.tokensAfter - diff.tokensBefore
144
+
145
+ // Create maps for quick lookup
146
+ const oldMap = new Map(oldSections.map((s) => [s.name.toLowerCase(), s]))
147
+ const newMap = new Map(newSections.map((s) => [s.name.toLowerCase(), s]))
148
+
149
+ // Find preserved sections in old content
150
+ for (const section of oldSections) {
151
+ if (isPreservedSection(section.content)) {
152
+ diff.preserved.push({
153
+ name: section.name,
154
+ lineCount: section.content.split('\n').length,
155
+ })
156
+ }
157
+ }
158
+
159
+ // Find added and modified sections
160
+ for (const newSection of newSections) {
161
+ const key = newSection.name.toLowerCase()
162
+ const oldSection = oldMap.get(key)
163
+
164
+ if (!oldSection) {
165
+ // New section
166
+ diff.added.push({
167
+ name: newSection.name,
168
+ type: 'added',
169
+ after: newSection.content,
170
+ lineCount: newSection.content.split('\n').length,
171
+ })
172
+ diff.hasChanges = true
173
+ } else if (oldSection.content.trim() !== newSection.content.trim()) {
174
+ // Modified section (skip if preserved)
175
+ if (!isPreservedSection(oldSection.content)) {
176
+ diff.modified.push({
177
+ name: newSection.name,
178
+ type: 'modified',
179
+ before: oldSection.content,
180
+ after: newSection.content,
181
+ lineCount: newSection.content.split('\n').length,
182
+ })
183
+ diff.hasChanges = true
184
+ }
185
+ }
186
+ }
187
+
188
+ // Find removed sections
189
+ for (const oldSection of oldSections) {
190
+ const key = oldSection.name.toLowerCase()
191
+ if (!newMap.has(key) && !isPreservedSection(oldSection.content)) {
192
+ diff.removed.push({
193
+ name: oldSection.name,
194
+ type: 'removed',
195
+ before: oldSection.content,
196
+ lineCount: oldSection.content.split('\n').length,
197
+ })
198
+ diff.hasChanges = true
199
+ }
200
+ }
201
+
202
+ return diff
203
+ }
204
+
205
+ // =============================================================================
206
+ // Diff Formatting
207
+ // =============================================================================
208
+
209
+ /**
210
+ * Format diff for terminal display
211
+ */
212
+ export function formatDiffPreview(diff: SyncDiff, options: DiffOptions = {}): string {
213
+ const { colorize = true } = options
214
+ const lines: string[] = []
215
+
216
+ const green = colorize ? chalk.green : (s: string) => s
217
+ const red = colorize ? chalk.red : (s: string) => s
218
+ const yellow = colorize ? chalk.yellow : (s: string) => s
219
+ const dim = colorize ? chalk.dim : (s: string) => s
220
+ const bold = colorize ? chalk.bold : (s: string) => s
221
+
222
+ if (!diff.hasChanges) {
223
+ lines.push(dim('No changes detected (context is up to date)'))
224
+ return lines.join('\n')
225
+ }
226
+
227
+ lines.push('')
228
+ lines.push(bold('📋 Changes to context files:'))
229
+ lines.push('')
230
+
231
+ // Added sections
232
+ if (diff.added.length > 0) {
233
+ for (const section of diff.added) {
234
+ lines.push(green(`+ │ + ${section.name} (new)`))
235
+ }
236
+ }
237
+
238
+ // Modified sections
239
+ if (diff.modified.length > 0) {
240
+ for (const section of diff.modified) {
241
+ lines.push(yellow(`~ │ ${section.name} (modified)`))
242
+ }
243
+ }
244
+
245
+ // Removed sections
246
+ if (diff.removed.length > 0) {
247
+ for (const section of diff.removed) {
248
+ lines.push(red(`- │ - ${section.name} (removed)`))
249
+ }
250
+ }
251
+
252
+ // Preserved sections
253
+ if (diff.preserved.length > 0) {
254
+ lines.push('')
255
+ lines.push(dim(' ## Your Customizations'))
256
+ for (const section of diff.preserved) {
257
+ lines.push(dim(` │ ✓ ${section.name} (${section.lineCount} lines preserved)`))
258
+ }
259
+ }
260
+
261
+ // Summary
262
+ lines.push('')
263
+ lines.push(dim('────────────────────────────────'))
264
+
265
+ const summaryParts: string[] = []
266
+ if (diff.added.length > 0) summaryParts.push(green(`+${diff.added.length} added`))
267
+ if (diff.modified.length > 0) summaryParts.push(yellow(`~${diff.modified.length} modified`))
268
+ if (diff.removed.length > 0) summaryParts.push(red(`-${diff.removed.length} removed`))
269
+
270
+ lines.push(`Summary: ${summaryParts.join(', ') || 'no changes'}`)
271
+
272
+ // Token delta
273
+ const tokenSign = diff.tokenDelta >= 0 ? '+' : ''
274
+ const tokenColor = diff.tokenDelta >= 0 ? green : red
275
+ lines.push(
276
+ `Tokens: ${diff.tokensBefore.toLocaleString()} → ${diff.tokensAfter.toLocaleString()} (${tokenColor(tokenSign + diff.tokenDelta.toLocaleString())})`
277
+ )
278
+
279
+ lines.push('')
280
+
281
+ return lines.join('\n')
282
+ }
283
+
284
+ /**
285
+ * Format full git-style diff
286
+ */
287
+ export function formatFullDiff(diff: SyncDiff, options: DiffOptions = {}): string {
288
+ const { colorize = true } = options
289
+ const lines: string[] = []
290
+
291
+ const green = colorize ? chalk.green : (s: string) => s
292
+ const red = colorize ? chalk.red : (s: string) => s
293
+ const cyan = colorize ? chalk.cyan : (s: string) => s
294
+ const dim = colorize ? chalk.dim : (s: string) => s
295
+
296
+ // Added sections
297
+ for (const section of diff.added) {
298
+ lines.push(cyan(`@@ +${section.name} @@`))
299
+ if (section.after) {
300
+ for (const line of section.after.split('\n')) {
301
+ lines.push(green(`+ ${line}`))
302
+ }
303
+ }
304
+ lines.push('')
305
+ }
306
+
307
+ // Modified sections
308
+ for (const section of diff.modified) {
309
+ lines.push(cyan(`@@ ${section.name} @@`))
310
+ if (section.before) {
311
+ for (const line of section.before.split('\n').slice(0, 5)) {
312
+ lines.push(red(`- ${line}`))
313
+ }
314
+ if (section.before.split('\n').length > 5) {
315
+ lines.push(dim(` ... ${section.before.split('\n').length - 5} more lines`))
316
+ }
317
+ }
318
+ if (section.after) {
319
+ for (const line of section.after.split('\n').slice(0, 5)) {
320
+ lines.push(green(`+ ${line}`))
321
+ }
322
+ if (section.after.split('\n').length > 5) {
323
+ lines.push(dim(` ... ${section.after.split('\n').length - 5} more lines`))
324
+ }
325
+ }
326
+ lines.push('')
327
+ }
328
+
329
+ // Removed sections
330
+ for (const section of diff.removed) {
331
+ lines.push(cyan(`@@ -${section.name} @@`))
332
+ if (section.before) {
333
+ for (const line of section.before.split('\n').slice(0, 5)) {
334
+ lines.push(red(`- ${line}`))
335
+ }
336
+ if (section.before.split('\n').length > 5) {
337
+ lines.push(dim(` ... ${section.before.split('\n').length - 5} more lines`))
338
+ }
339
+ }
340
+ lines.push('')
341
+ }
342
+
343
+ return lines.join('\n')
344
+ }
345
+
346
+ // =============================================================================
347
+ // Exports
348
+ // =============================================================================
349
+
350
+ export default {
351
+ generateSyncDiff,
352
+ formatDiffPreview,
353
+ formatFullDiff,
354
+ estimateTokens,
355
+ parseMarkdownSections,
356
+ }
@@ -31,6 +31,7 @@ import pathManager from '../infrastructure/path-manager'
31
31
  import { metricsStorage } from '../storage/metrics-storage'
32
32
  import dateHelper from '../utils/date-helper'
33
33
  import { ContextFileGenerator } from './context-generator'
34
+ import type { SyncDiff } from './diff-generator'
34
35
  import { type StackDetection, StackDetector } from './stack-detector'
35
36
 
36
37
  const execAsync = promisify(exec)
@@ -104,10 +105,15 @@ interface SyncResult {
104
105
  aiTools: AIToolResult[]
105
106
  syncMetrics?: SyncMetrics
106
107
  error?: string
108
+ // Preview mode fields
109
+ isPreview?: boolean
110
+ previewDiff?: SyncDiff
107
111
  }
108
112
 
109
113
  interface SyncOptions {
110
114
  aiTools?: string[] // AI tools to generate context for (default: claude, cursor)
115
+ preview?: boolean // If true, return diff without applying changes
116
+ skipConfirmation?: boolean // If true, apply changes without confirmation (--yes flag)
111
117
  }
112
118
 
113
119
  // ============================================================================
@@ -43,6 +43,10 @@ export interface Memory {
43
43
  userTriggered: boolean
44
44
  createdAt: string
45
45
  updatedAt: string
46
+ /** Confidence level for this memory (optional for backward compatibility) */
47
+ confidence?: ConfidenceLevel
48
+ /** Number of times this memory was reinforced */
49
+ observationCount?: number
46
50
  }
47
51
 
48
52
  /**
@@ -123,8 +127,10 @@ export interface Decision {
123
127
  count: number
124
128
  firstSeen: string
125
129
  lastSeen: string
126
- confidence: 'low' | 'medium' | 'high'
130
+ confidence: ConfidenceLevel
127
131
  contexts: string[]
132
+ /** Whether user explicitly confirmed this decision */
133
+ userConfirmed?: boolean
128
134
  }
129
135
 
130
136
  /**
@@ -141,14 +147,47 @@ export interface Workflow {
141
147
  successRate?: number
142
148
  /** Steps in the workflow */
143
149
  steps?: string[]
150
+ /** Confidence level based on execution count */
151
+ confidence?: ConfidenceLevel
152
+ /** Whether user explicitly confirmed this workflow */
153
+ userConfirmed?: boolean
144
154
  }
145
155
 
146
156
  /**
147
- * A user preference value.
157
+ * Confidence level for stored preferences and decisions.
158
+ * @see PRJ-104
159
+ */
160
+ export type ConfidenceLevel = 'low' | 'medium' | 'high'
161
+
162
+ /**
163
+ * Calculate confidence level from observation count.
164
+ * - low: 1-2 observations
165
+ * - medium: 3-5 observations
166
+ * - high: 6+ observations or explicit user confirmation
167
+ */
168
+ export function calculateConfidence(
169
+ count: number,
170
+ userConfirmed: boolean = false
171
+ ): ConfidenceLevel {
172
+ if (userConfirmed) return 'high'
173
+ if (count >= 6) return 'high'
174
+ if (count >= 3) return 'medium'
175
+ return 'low'
176
+ }
177
+
178
+ /**
179
+ * A user preference value with confidence scoring.
180
+ * @see PRJ-104
148
181
  */
149
182
  export interface Preference {
150
183
  value: string | number | boolean
151
184
  updatedAt: string
185
+ /** Confidence level based on observations */
186
+ confidence: ConfidenceLevel
187
+ /** Number of times this preference was observed */
188
+ observationCount: number
189
+ /** Whether user explicitly confirmed this preference */
190
+ userConfirmed: boolean
152
191
  }
153
192
 
154
193
  /**