prjct-cli 0.45.4 → 0.46.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 CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.46.0] - 2026-01-30
4
+
5
+ ### Features
6
+
7
+ - preserve user customizations during sync - PRJ-115 (#70)
8
+
9
+
10
+ ## [0.46.0] - 2026-01-30
11
+
12
+ ### Added
13
+
14
+ - **Preserve user customizations during sync** (PRJ-115)
15
+ - Wrap custom content in `<!-- prjct:preserve -->` markers
16
+ - Content survives `p. sync` regeneration
17
+ - Supports named sections: `<!-- prjct:preserve:my-rules -->`
18
+ - Works with CLAUDE.md, .cursorrules, and all context files
19
+
20
+
21
+ ## [0.45.5] - 2026-01-30
22
+
23
+ ### Bug Fixes
24
+
25
+ - implement silent memory application - PRJ-103 (#69)
26
+
27
+
28
+ ## [0.45.5] - 2026-01-29
29
+
30
+ ### Changed
31
+
32
+ - Remove meta-commentary from memory output (PRJ-103)
33
+ - "LEARNED PATTERNS" → "PROJECT DEFAULTS"
34
+ - "RELEVANT MEMORIES" → "CONTEXT"
35
+ - "Learned Patterns" → "Project Conventions"
36
+
37
+ ### Added
38
+
39
+ - `lint:meta` script to catch meta-commentary anti-patterns
40
+
41
+
3
42
  ## [0.45.4] - 2026-01-29
4
43
 
5
44
  ### Bug Fixes
@@ -0,0 +1,216 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import {
3
+ extractPreservedContent,
4
+ extractPreservedSections,
5
+ hasPreservedSections,
6
+ mergePreservedSections,
7
+ stripPreservedSections,
8
+ validatePreserveBlocks,
9
+ wrapInPreserveMarkers,
10
+ } from '../../utils/preserve-sections'
11
+
12
+ describe('preserve-sections', () => {
13
+ describe('extractPreservedSections', () => {
14
+ it('should extract a single preserved section', () => {
15
+ const content = `# Header
16
+
17
+ <!-- prjct:preserve -->
18
+ My custom content
19
+ <!-- /prjct:preserve -->
20
+
21
+ # Footer`
22
+
23
+ const sections = extractPreservedSections(content)
24
+ expect(sections).toHaveLength(1)
25
+ expect(sections[0].content).toContain('My custom content')
26
+ })
27
+
28
+ it('should extract multiple preserved sections', () => {
29
+ const content = `# Header
30
+
31
+ <!-- prjct:preserve -->
32
+ First section
33
+ <!-- /prjct:preserve -->
34
+
35
+ Some content
36
+
37
+ <!-- prjct:preserve -->
38
+ Second section
39
+ <!-- /prjct:preserve -->`
40
+
41
+ const sections = extractPreservedSections(content)
42
+ expect(sections).toHaveLength(2)
43
+ expect(sections[0].content).toContain('First section')
44
+ expect(sections[1].content).toContain('Second section')
45
+ })
46
+
47
+ it('should handle named sections', () => {
48
+ const content = `<!-- prjct:preserve:custom-rules -->
49
+ My rules
50
+ <!-- /prjct:preserve -->`
51
+
52
+ const sections = extractPreservedSections(content)
53
+ expect(sections).toHaveLength(1)
54
+ expect(sections[0].id).toBe('custom-rules')
55
+ })
56
+
57
+ it('should return empty array when no preserved sections', () => {
58
+ const content = '# Just normal content'
59
+ const sections = extractPreservedSections(content)
60
+ expect(sections).toHaveLength(0)
61
+ })
62
+
63
+ it('should ignore unclosed preserve blocks', () => {
64
+ const content = `<!-- prjct:preserve -->
65
+ No closing tag here`
66
+
67
+ const sections = extractPreservedSections(content)
68
+ expect(sections).toHaveLength(0)
69
+ })
70
+ })
71
+
72
+ describe('extractPreservedContent', () => {
73
+ it('should extract inner content without markers', () => {
74
+ const content = `<!-- prjct:preserve -->
75
+ My content
76
+ <!-- /prjct:preserve -->`
77
+
78
+ const inner = extractPreservedContent(content)
79
+ expect(inner).toHaveLength(1)
80
+ expect(inner[0]).toBe('My content')
81
+ })
82
+ })
83
+
84
+ describe('hasPreservedSections', () => {
85
+ it('should return true when preserved sections exist', () => {
86
+ const content = '<!-- prjct:preserve -->content<!-- /prjct:preserve -->'
87
+ expect(hasPreservedSections(content)).toBe(true)
88
+ })
89
+
90
+ it('should return false when no preserved sections', () => {
91
+ const content = '# Normal markdown'
92
+ expect(hasPreservedSections(content)).toBe(false)
93
+ })
94
+ })
95
+
96
+ describe('mergePreservedSections', () => {
97
+ it('should append preserved sections to new content', () => {
98
+ const oldContent = `# Old Header
99
+
100
+ <!-- prjct:preserve -->
101
+ # My Rules
102
+ - Use tabs
103
+ <!-- /prjct:preserve -->`
104
+
105
+ const newContent = `# New Generated Content
106
+
107
+ This is fresh.`
108
+
109
+ const merged = mergePreservedSections(newContent, oldContent)
110
+
111
+ expect(merged).toContain('# New Generated Content')
112
+ expect(merged).toContain('My Rules')
113
+ expect(merged).toContain('Use tabs')
114
+ expect(merged).toContain('prjct:preserve')
115
+ })
116
+
117
+ it('should return new content unchanged when no preserved sections', () => {
118
+ const oldContent = '# Old content without preserve markers'
119
+ const newContent = '# New content'
120
+
121
+ const merged = mergePreservedSections(newContent, oldContent)
122
+ expect(merged).toBe(newContent)
123
+ })
124
+
125
+ it('should preserve multiple sections', () => {
126
+ const oldContent = `<!-- prjct:preserve -->
127
+ Section 1
128
+ <!-- /prjct:preserve -->
129
+
130
+ <!-- prjct:preserve -->
131
+ Section 2
132
+ <!-- /prjct:preserve -->`
133
+
134
+ const newContent = '# New'
135
+
136
+ const merged = mergePreservedSections(newContent, oldContent)
137
+ expect(merged).toContain('Section 1')
138
+ expect(merged).toContain('Section 2')
139
+ })
140
+ })
141
+
142
+ describe('wrapInPreserveMarkers', () => {
143
+ it('should wrap content with default markers', () => {
144
+ const content = 'My content'
145
+ const wrapped = wrapInPreserveMarkers(content)
146
+
147
+ expect(wrapped).toContain('<!-- prjct:preserve -->')
148
+ expect(wrapped).toContain('My content')
149
+ expect(wrapped).toContain('<!-- /prjct:preserve -->')
150
+ })
151
+
152
+ it('should wrap content with named markers', () => {
153
+ const content = 'My content'
154
+ const wrapped = wrapInPreserveMarkers(content, 'custom')
155
+
156
+ expect(wrapped).toContain('<!-- prjct:preserve:custom -->')
157
+ })
158
+ })
159
+
160
+ describe('stripPreservedSections', () => {
161
+ it('should remove preserved sections from content', () => {
162
+ const content = `# Header
163
+
164
+ <!-- prjct:preserve -->
165
+ Custom stuff
166
+ <!-- /prjct:preserve -->
167
+
168
+ # Footer`
169
+
170
+ const stripped = stripPreservedSections(content)
171
+ expect(stripped).toContain('# Header')
172
+ expect(stripped).toContain('# Footer')
173
+ expect(stripped).not.toContain('Custom stuff')
174
+ expect(stripped).not.toContain('prjct:preserve')
175
+ })
176
+
177
+ it('should return content unchanged when no preserved sections', () => {
178
+ const content = '# Normal content'
179
+ const stripped = stripPreservedSections(content)
180
+ expect(stripped).toBe(content)
181
+ })
182
+ })
183
+
184
+ describe('validatePreserveBlocks', () => {
185
+ it('should validate correct blocks', () => {
186
+ const content = `<!-- prjct:preserve -->
187
+ Content
188
+ <!-- /prjct:preserve -->`
189
+
190
+ const result = validatePreserveBlocks(content)
191
+ expect(result.valid).toBe(true)
192
+ expect(result.errors).toHaveLength(0)
193
+ })
194
+
195
+ it('should detect mismatched markers', () => {
196
+ const content = `<!-- prjct:preserve -->
197
+ Content without closing`
198
+
199
+ const result = validatePreserveBlocks(content)
200
+ expect(result.valid).toBe(false)
201
+ expect(result.errors.length).toBeGreaterThan(0)
202
+ })
203
+
204
+ it('should detect nested blocks', () => {
205
+ const content = `<!-- prjct:preserve -->
206
+ <!-- prjct:preserve -->
207
+ Nested
208
+ <!-- /prjct:preserve -->
209
+ <!-- /prjct:preserve -->`
210
+
211
+ const result = validatePreserveBlocks(content)
212
+ expect(result.valid).toBe(false)
213
+ expect(result.errors.some((e) => e.includes('Nested'))).toBe(true)
214
+ })
215
+ })
216
+ })
@@ -174,7 +174,7 @@ class PromptBuilder {
174
174
  try {
175
175
  const patterns = await outcomeAnalyzer.detectPatterns(projectId)
176
176
  if (patterns.length > 0) {
177
- parts.push('**Learned Patterns**')
177
+ parts.push('**Project Conventions**')
178
178
  for (const pattern of patterns.slice(0, 3)) {
179
179
  parts.push(`- ${pattern.description}`)
180
180
  if (pattern.suggestedAction) {
@@ -489,7 +489,7 @@ class PromptBuilder {
489
489
 
490
490
  // P1.1: Learned Patterns
491
491
  if (learnedPatterns && Object.keys(learnedPatterns).some((k) => learnedPatterns[k])) {
492
- parts.push('\n## LEARNED PATTERNS (use these, do NOT ask user)\n')
492
+ parts.push('\n## PROJECT DEFAULTS (apply automatically)\n')
493
493
  for (const [key, value] of Object.entries(learnedPatterns)) {
494
494
  if (value) {
495
495
  parts.push(`- ${key}: ${value}\n`)
@@ -511,7 +511,7 @@ class PromptBuilder {
511
511
 
512
512
  // P3.3: Relevant Memories
513
513
  if (relevantMemories && relevantMemories.length > 0) {
514
- parts.push('\n## RELEVANT MEMORIES (apply these learnings)\n')
514
+ parts.push('\n## CONTEXT (apply these)\n')
515
515
  for (const memory of relevantMemories) {
516
516
  parts.push(`- **${memory.title}**: ${memory.content}\n`)
517
517
  if (memory.tags && memory.tags.length > 0) {
@@ -7,6 +7,7 @@
7
7
 
8
8
  import fs from 'node:fs/promises'
9
9
  import path from 'node:path'
10
+ import { mergePreservedSections } from '../utils/preserve-sections'
10
11
  import { getFormatter, type ProjectContext } from './formatters'
11
12
  import { AI_TOOLS, type AIToolConfig, DEFAULT_AI_TOOLS, getAIToolConfig } from './registry'
12
13
 
@@ -71,7 +72,7 @@ async function generateForTool(
71
72
 
72
73
  try {
73
74
  // Generate content
74
- const content = formatter(context, config)
75
+ let content = formatter(context, config)
75
76
 
76
77
  // Determine output path
77
78
  let outputPath: string
@@ -84,6 +85,14 @@ async function generateForTool(
84
85
  // Ensure directory exists
85
86
  await fs.mkdir(path.dirname(outputPath), { recursive: true })
86
87
 
88
+ // Read existing file to preserve user customizations
89
+ try {
90
+ const existingContent = await fs.readFile(outputPath, 'utf-8')
91
+ content = mergePreservedSections(content, existingContent)
92
+ } catch {
93
+ // File doesn't exist yet - use generated content as-is
94
+ }
95
+
87
96
  // Write file
88
97
  await fs.writeFile(outputPath, content, 'utf-8')
89
98
 
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Lint rule: no-meta-commentary
4
+ *
5
+ * Catches AI memory meta-commentary patterns that should be avoided.
6
+ * Preferences should be presented as facts, not as memories.
7
+ *
8
+ * Anti-patterns:
9
+ * - "I remember you prefer..."
10
+ * - "Based on your history..."
11
+ * - "I've learned that..."
12
+ * - "From previous sessions..."
13
+ *
14
+ * Correct pattern:
15
+ * - "Use TypeScript strict mode" (not "I remember you like strict mode")
16
+ *
17
+ * @see PRJ-103
18
+ */
19
+
20
+ import { readdir, readFile } from 'node:fs/promises'
21
+ import { join, relative } from 'node:path'
22
+
23
+ const META_COMMENTARY_PATTERNS = [
24
+ // Direct memory references
25
+ /\bI remember\b/gi,
26
+ /\bI recall\b/gi,
27
+ /\bI('ve| have) learned\b/gi,
28
+
29
+ // History references
30
+ /\bBased on (your |)history\b/gi,
31
+ /\bFrom previous (session|conversation)s?\b/gi,
32
+ /\bIn (the |)past,? you\b/gi,
33
+
34
+ // Preference meta-commentary
35
+ /\byou (usually|typically|always|often) prefer\b/gi,
36
+ /\byou mentioned (before|earlier|previously)\b/gi,
37
+ /\blast time you\b/gi,
38
+
39
+ // Section headers that expose memory mechanism
40
+ /\bLEARNED PATTERNS\b/g,
41
+ /\bRELEVANT MEMORIES\b/g,
42
+ /\bLearned Patterns\b/g,
43
+ /\bRelevant Memories\b/g,
44
+ ]
45
+
46
+ // Files/directories to skip
47
+ const SKIP_PATTERNS = [
48
+ /node_modules/,
49
+ /\.git/,
50
+ /dist/,
51
+ /build/,
52
+ /coverage/,
53
+ /\.test\.ts$/,
54
+ /\.spec\.ts$/,
55
+ /lint-meta-commentary\.ts$/, // Skip self
56
+ /\/docs\//, // Skip documentation (describes features, not agent output)
57
+ ]
58
+
59
+ // Only check these extensions
60
+ const INCLUDE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.md']
61
+
62
+ interface Violation {
63
+ file: string
64
+ line: number
65
+ column: number
66
+ match: string
67
+ pattern: string
68
+ }
69
+
70
+ async function* walkDir(dir: string): AsyncGenerator<string> {
71
+ const entries = await readdir(dir, { withFileTypes: true })
72
+
73
+ for (const entry of entries) {
74
+ const fullPath = join(dir, entry.name)
75
+
76
+ if (SKIP_PATTERNS.some((p) => p.test(fullPath))) {
77
+ continue
78
+ }
79
+
80
+ if (entry.isDirectory()) {
81
+ yield* walkDir(fullPath)
82
+ } else if (entry.isFile() && INCLUDE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
83
+ yield fullPath
84
+ }
85
+ }
86
+ }
87
+
88
+ async function checkFile(filePath: string): Promise<Violation[]> {
89
+ const violations: Violation[] = []
90
+ const content = await readFile(filePath, 'utf-8')
91
+ const lines = content.split('\n')
92
+
93
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
94
+ const line = lines[lineNum]
95
+
96
+ // Skip comments - we only care about string literals that become agent output
97
+ const trimmed = line.trim()
98
+ if (
99
+ trimmed.startsWith('//') || // Inline comments
100
+ trimmed.startsWith('*') || // JSDoc/block comments
101
+ trimmed.startsWith('/*') || // Block comment start
102
+ line.includes('Anti-pattern') || // Documentation
103
+ line.includes('@see') || // JSDoc refs
104
+ line.includes('@example') // JSDoc examples
105
+ ) {
106
+ continue
107
+ }
108
+
109
+ for (const pattern of META_COMMENTARY_PATTERNS) {
110
+ // Reset lastIndex for global patterns
111
+ pattern.lastIndex = 0
112
+
113
+ let match: RegExpExecArray | null
114
+ while ((match = pattern.exec(line)) !== null) {
115
+ violations.push({
116
+ file: filePath,
117
+ line: lineNum + 1,
118
+ column: match.index + 1,
119
+ match: match[0],
120
+ pattern: pattern.source,
121
+ })
122
+ }
123
+ }
124
+ }
125
+
126
+ return violations
127
+ }
128
+
129
+ async function main() {
130
+ const rootDir = process.cwd()
131
+ const allViolations: Violation[] = []
132
+
133
+ console.log('Checking for meta-commentary patterns...\n')
134
+
135
+ for await (const filePath of walkDir(rootDir)) {
136
+ const violations = await checkFile(filePath)
137
+ if (violations.length > 0) {
138
+ allViolations.push(...violations)
139
+ }
140
+ }
141
+
142
+ if (allViolations.length === 0) {
143
+ console.log('✅ No meta-commentary patterns found\n')
144
+ process.exit(0)
145
+ }
146
+
147
+ console.log(`❌ Found ${allViolations.length} meta-commentary violation(s):\n`)
148
+
149
+ // Group by file
150
+ const byFile = new Map<string, Violation[]>()
151
+ for (const v of allViolations) {
152
+ const rel = relative(rootDir, v.file)
153
+ if (!byFile.has(rel)) {
154
+ byFile.set(rel, [])
155
+ }
156
+ byFile.get(rel)!.push(v)
157
+ }
158
+
159
+ for (const [file, violations] of byFile) {
160
+ console.log(` ${file}`)
161
+ for (const v of violations) {
162
+ console.log(` ${v.line}:${v.column} "${v.match}"`)
163
+ }
164
+ console.log('')
165
+ }
166
+
167
+ console.log('Fix: Present preferences as facts, not memories.')
168
+ console.log(' ❌ "I remember you prefer TypeScript"')
169
+ console.log(' ✅ "Use TypeScript"\n')
170
+
171
+ process.exit(1)
172
+ }
173
+
174
+ main().catch((err) => {
175
+ console.error('Error:', err.message)
176
+ process.exit(1)
177
+ })
@@ -12,6 +12,7 @@
12
12
  import fs from 'node:fs/promises'
13
13
  import path from 'node:path'
14
14
  import dateHelper from '../utils/date-helper'
15
+ import { mergePreservedSections } from '../utils/preserve-sections'
15
16
 
16
17
  // ============================================================================
17
18
  // TYPES
@@ -180,7 +181,17 @@ Load from \`~/.prjct-cli/projects/${this.config.projectId}/agents/\`:
180
181
  **Domain**: ${domainAgents.join(', ') || 'none'}
181
182
  `
182
183
 
183
- await fs.writeFile(path.join(contextPath, 'CLAUDE.md'), content, 'utf-8')
184
+ // Preserve user customizations from existing file
185
+ const claudePath = path.join(contextPath, 'CLAUDE.md')
186
+ let finalContent = content
187
+ try {
188
+ const existingContent = await fs.readFile(claudePath, 'utf-8')
189
+ finalContent = mergePreservedSections(content, existingContent)
190
+ } catch {
191
+ // File doesn't exist yet - use generated content as-is
192
+ }
193
+
194
+ await fs.writeFile(claudePath, finalContent, 'utf-8')
184
195
  }
185
196
 
186
197
  /**
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Preserve Sections Utility
3
+ *
4
+ * Extracts and preserves user-customized sections during file regeneration.
5
+ * Users can mark sections with preserve markers to survive sync.
6
+ *
7
+ * Usage in CLAUDE.md or other context files:
8
+ * ```markdown
9
+ * <!-- prjct:preserve -->
10
+ * # My Custom Rules
11
+ * - Always use tabs
12
+ * - Prefer functional patterns
13
+ * <!-- /prjct:preserve -->
14
+ * ```
15
+ *
16
+ * @see PRJ-115
17
+ * @module utils/preserve-sections
18
+ */
19
+
20
+ export interface PreservedSection {
21
+ id: string
22
+ content: string
23
+ startIndex: number
24
+ endIndex: number
25
+ }
26
+
27
+ // Markers for preserved sections
28
+ const PRESERVE_START = '<!-- prjct:preserve -->'
29
+ const PRESERVE_END = '<!-- /prjct:preserve -->'
30
+ const PRESERVE_END_PATTERN = '<!-- /prjct:preserve -->'
31
+
32
+ // Named section markers (optional identifier) - create fresh regex each time
33
+ // Uses [\w-]+ to allow hyphens in section names (e.g., "custom-rules")
34
+ function createPreserveStartRegex(): RegExp {
35
+ return /<!-- prjct:preserve(?::([\w-]+))? -->/g
36
+ }
37
+
38
+ /**
39
+ * Extract all preserved sections from content
40
+ */
41
+ export function extractPreservedSections(content: string): PreservedSection[] {
42
+ const sections: PreservedSection[] = []
43
+ const regex = createPreserveStartRegex()
44
+
45
+ let match: RegExpExecArray | null
46
+ let sectionIndex = 0
47
+
48
+ while ((match = regex.exec(content)) !== null) {
49
+ const startIndex = match.index
50
+ const startTag = match[0]
51
+ const sectionId = match[1] || `section-${sectionIndex++}`
52
+
53
+ // Find the closing tag
54
+ const endTagStart = content.indexOf(PRESERVE_END_PATTERN, startIndex + startTag.length)
55
+
56
+ if (endTagStart === -1) {
57
+ // No closing tag found - skip this section
58
+ continue
59
+ }
60
+
61
+ const endIndex = endTagStart + PRESERVE_END_PATTERN.length
62
+
63
+ // Extract the content between markers (including markers)
64
+ const fullContent = content.substring(startIndex, endIndex)
65
+
66
+ sections.push({
67
+ id: sectionId,
68
+ content: fullContent,
69
+ startIndex,
70
+ endIndex,
71
+ })
72
+ }
73
+
74
+ return sections
75
+ }
76
+
77
+ /**
78
+ * Extract the inner content of preserved sections (without markers)
79
+ */
80
+ export function extractPreservedContent(content: string): string[] {
81
+ const sections = extractPreservedSections(content)
82
+
83
+ return sections.map((section) => {
84
+ // Remove the markers to get just the inner content
85
+ let inner = section.content
86
+ inner = inner.replace(createPreserveStartRegex(), '').replace(PRESERVE_END_PATTERN, '')
87
+ return inner.trim()
88
+ })
89
+ }
90
+
91
+ /**
92
+ * Check if content has any preserved sections
93
+ */
94
+ export function hasPreservedSections(content: string): boolean {
95
+ return content.includes(PRESERVE_START) || createPreserveStartRegex().test(content)
96
+ }
97
+
98
+ /**
99
+ * Merge preserved sections from old content into new content
100
+ *
101
+ * Strategy:
102
+ * 1. Extract preserved sections from old content
103
+ * 2. Append them to the end of new content
104
+ * 3. Ensure proper spacing
105
+ *
106
+ * @param newContent - Freshly generated content
107
+ * @param oldContent - Previous content with user customizations
108
+ * @returns Merged content with preserved sections
109
+ */
110
+ export function mergePreservedSections(newContent: string, oldContent: string): string {
111
+ const preservedSections = extractPreservedSections(oldContent)
112
+
113
+ if (preservedSections.length === 0) {
114
+ return newContent
115
+ }
116
+
117
+ // Build the merged content
118
+ let merged = newContent.trimEnd()
119
+
120
+ // Add separator before preserved sections
121
+ merged += '\n\n---\n\n'
122
+ merged += '## Your Customizations\n\n'
123
+ merged += '_The sections below are preserved during sync. Edit freely._\n\n'
124
+
125
+ // Append each preserved section
126
+ for (const section of preservedSections) {
127
+ merged += section.content
128
+ merged += '\n\n'
129
+ }
130
+
131
+ return merged.trimEnd() + '\n'
132
+ }
133
+
134
+ /**
135
+ * Create an empty preserve block for users to customize
136
+ */
137
+ export function createEmptyPreserveBlock(title?: string): string {
138
+ const header = title ? `\n# ${title}\n` : '\n'
139
+ return `${PRESERVE_START}${header}<!-- Add your custom instructions here -->\n${PRESERVE_END}`
140
+ }
141
+
142
+ /**
143
+ * Wrap content in preserve markers
144
+ */
145
+ export function wrapInPreserveMarkers(content: string, id?: string): string {
146
+ const startTag = id ? `<!-- prjct:preserve:${id} -->` : PRESERVE_START
147
+ return `${startTag}\n${content}\n${PRESERVE_END}`
148
+ }
149
+
150
+ /**
151
+ * Remove all preserved sections from content
152
+ * (Useful for getting the auto-generated portion only)
153
+ */
154
+ export function stripPreservedSections(content: string): string {
155
+ const sections = extractPreservedSections(content)
156
+
157
+ if (sections.length === 0) {
158
+ return content
159
+ }
160
+
161
+ // Remove sections in reverse order to preserve indices
162
+ let result = content
163
+ for (let i = sections.length - 1; i >= 0; i--) {
164
+ const section = sections[i]
165
+ result = result.substring(0, section.startIndex) + result.substring(section.endIndex)
166
+ }
167
+
168
+ // Clean up any resulting double newlines
169
+ result = result.replace(/\n{3,}/g, '\n\n')
170
+
171
+ return result.trim()
172
+ }
173
+
174
+ /**
175
+ * Validate that all preserve blocks are properly closed
176
+ */
177
+ export function validatePreserveBlocks(content: string): {
178
+ valid: boolean
179
+ errors: string[]
180
+ } {
181
+ const errors: string[] = []
182
+
183
+ const startMatches = content.match(/<!-- prjct:preserve(?::\w+)? -->/g) || []
184
+ const endMatches = content.match(/<!-- \/prjct:preserve -->/g) || []
185
+
186
+ if (startMatches.length !== endMatches.length) {
187
+ errors.push(
188
+ `Mismatched preserve markers: ${startMatches.length} opening, ${endMatches.length} closing`
189
+ )
190
+ }
191
+
192
+ // Check for nested blocks (not supported)
193
+ let depth = 0
194
+ let maxDepth = 0
195
+ const lines = content.split('\n')
196
+
197
+ for (let i = 0; i < lines.length; i++) {
198
+ const line = lines[i]
199
+ if (/<!-- prjct:preserve(?::\w+)? -->/.test(line)) {
200
+ depth++
201
+ maxDepth = Math.max(maxDepth, depth)
202
+ }
203
+ if (line.includes(PRESERVE_END_PATTERN)) {
204
+ depth--
205
+ }
206
+ if (depth > 1) {
207
+ errors.push(`Nested preserve blocks detected at line ${i + 1} (not supported)`)
208
+ }
209
+ if (depth < 0) {
210
+ errors.push(`Unexpected closing marker at line ${i + 1}`)
211
+ }
212
+ }
213
+
214
+ return {
215
+ valid: errors.length === 0,
216
+ errors,
217
+ }
218
+ }
@@ -12055,7 +12055,7 @@ var init_prompt_builder = __esm({
12055
12055
  try {
12056
12056
  const patterns = await analyzer_default.detectPatterns(projectId);
12057
12057
  if (patterns.length > 0) {
12058
- parts.push("**Learned Patterns**");
12058
+ parts.push("**Project Conventions**");
12059
12059
  for (const pattern of patterns.slice(0, 3)) {
12060
12060
  parts.push(`- ${pattern.description}`);
12061
12061
  if (pattern.suggestedAction) {
@@ -12315,7 +12315,7 @@ Stack: ${stack}
12315
12315
  }
12316
12316
  parts.push(this.buildCriticalRules());
12317
12317
  if (learnedPatterns && Object.keys(learnedPatterns).some((k) => learnedPatterns[k])) {
12318
- parts.push("\n## LEARNED PATTERNS (use these, do NOT ask user)\n");
12318
+ parts.push("\n## PROJECT DEFAULTS (apply automatically)\n");
12319
12319
  for (const [key, value] of Object.entries(learnedPatterns)) {
12320
12320
  if (value) {
12321
12321
  parts.push(`- ${key}: ${value}
@@ -12337,7 +12337,7 @@ Stack: ${stack}
12337
12337
  `);
12338
12338
  }
12339
12339
  if (relevantMemories && relevantMemories.length > 0) {
12340
- parts.push("\n## RELEVANT MEMORIES (apply these learnings)\n");
12340
+ parts.push("\n## CONTEXT (apply these)\n");
12341
12341
  for (const memory of relevantMemories) {
12342
12342
  parts.push(`- **${memory.title}**: ${memory.content}
12343
12343
  `);
@@ -17217,6 +17217,60 @@ var init_formatters = __esm({
17217
17217
  }
17218
17218
  });
17219
17219
 
17220
+ // core/utils/preserve-sections.ts
17221
+ function createPreserveStartRegex() {
17222
+ return /<!-- prjct:preserve(?::([\w-]+))? -->/g;
17223
+ }
17224
+ function extractPreservedSections(content) {
17225
+ const sections = [];
17226
+ const regex = createPreserveStartRegex();
17227
+ let match;
17228
+ let sectionIndex = 0;
17229
+ while ((match = regex.exec(content)) !== null) {
17230
+ const startIndex = match.index;
17231
+ const startTag = match[0];
17232
+ const sectionId = match[1] || `section-${sectionIndex++}`;
17233
+ const endTagStart = content.indexOf(PRESERVE_END_PATTERN, startIndex + startTag.length);
17234
+ if (endTagStart === -1) {
17235
+ continue;
17236
+ }
17237
+ const endIndex = endTagStart + PRESERVE_END_PATTERN.length;
17238
+ const fullContent = content.substring(startIndex, endIndex);
17239
+ sections.push({
17240
+ id: sectionId,
17241
+ content: fullContent,
17242
+ startIndex,
17243
+ endIndex
17244
+ });
17245
+ }
17246
+ return sections;
17247
+ }
17248
+ function mergePreservedSections(newContent, oldContent) {
17249
+ const preservedSections = extractPreservedSections(oldContent);
17250
+ if (preservedSections.length === 0) {
17251
+ return newContent;
17252
+ }
17253
+ let merged = newContent.trimEnd();
17254
+ merged += "\n\n---\n\n";
17255
+ merged += "## Your Customizations\n\n";
17256
+ merged += "_The sections below are preserved during sync. Edit freely._\n\n";
17257
+ for (const section of preservedSections) {
17258
+ merged += section.content;
17259
+ merged += "\n\n";
17260
+ }
17261
+ return merged.trimEnd() + "\n";
17262
+ }
17263
+ var PRESERVE_END_PATTERN;
17264
+ var init_preserve_sections = __esm({
17265
+ "core/utils/preserve-sections.ts"() {
17266
+ "use strict";
17267
+ PRESERVE_END_PATTERN = "<!-- /prjct:preserve -->";
17268
+ __name(createPreserveStartRegex, "createPreserveStartRegex");
17269
+ __name(extractPreservedSections, "extractPreservedSections");
17270
+ __name(mergePreservedSections, "mergePreservedSections");
17271
+ }
17272
+ });
17273
+
17220
17274
  // core/ai-tools/registry.ts
17221
17275
  import { execSync as execSync4 } from "node:child_process";
17222
17276
  import fs34 from "node:fs";
@@ -17356,7 +17410,7 @@ async function generateForTool(context2, config, globalPath, repoPath) {
17356
17410
  };
17357
17411
  }
17358
17412
  try {
17359
- const content = formatter(context2, config);
17413
+ let content = formatter(context2, config);
17360
17414
  let outputPath;
17361
17415
  if (config.outputPath === "repo") {
17362
17416
  outputPath = path38.join(repoPath, config.outputFile);
@@ -17364,6 +17418,11 @@ async function generateForTool(context2, config, globalPath, repoPath) {
17364
17418
  outputPath = path38.join(globalPath, "context", config.outputFile);
17365
17419
  }
17366
17420
  await fs35.mkdir(path38.dirname(outputPath), { recursive: true });
17421
+ try {
17422
+ const existingContent = await fs35.readFile(outputPath, "utf-8");
17423
+ content = mergePreservedSections(content, existingContent);
17424
+ } catch {
17425
+ }
17367
17426
  await fs35.writeFile(outputPath, content, "utf-8");
17368
17427
  return {
17369
17428
  toolId: config.id,
@@ -17384,6 +17443,7 @@ async function generateForTool(context2, config, globalPath, repoPath) {
17384
17443
  var init_generator2 = __esm({
17385
17444
  "core/ai-tools/generator.ts"() {
17386
17445
  "use strict";
17446
+ init_preserve_sections();
17387
17447
  init_formatters();
17388
17448
  init_registry();
17389
17449
  __name(generateAIToolContexts, "generateAIToolContexts");
@@ -17409,6 +17469,7 @@ var init_context_generator = __esm({
17409
17469
  "core/services/context-generator.ts"() {
17410
17470
  "use strict";
17411
17471
  init_date_helper();
17472
+ init_preserve_sections();
17412
17473
  ContextFileGenerator = class {
17413
17474
  static {
17414
17475
  __name(this, "ContextFileGenerator");
@@ -17515,7 +17576,14 @@ Load from \`~/.prjct-cli/projects/${this.config.projectId}/agents/\`:
17515
17576
  **Workflow**: ${workflowAgents.join(", ")}
17516
17577
  **Domain**: ${domainAgents.join(", ") || "none"}
17517
17578
  `;
17518
- await fs36.writeFile(path39.join(contextPath, "CLAUDE.md"), content, "utf-8");
17579
+ const claudePath = path39.join(contextPath, "CLAUDE.md");
17580
+ let finalContent = content;
17581
+ try {
17582
+ const existingContent = await fs36.readFile(claudePath, "utf-8");
17583
+ finalContent = mergePreservedSections(content, existingContent);
17584
+ } catch {
17585
+ }
17586
+ await fs36.writeFile(claudePath, finalContent, "utf-8");
17519
17587
  }
17520
17588
  /**
17521
17589
  * Generate now.md - current task status
@@ -23330,7 +23398,7 @@ var require_package = __commonJS({
23330
23398
  "package.json"(exports, module) {
23331
23399
  module.exports = {
23332
23400
  name: "prjct-cli",
23333
- version: "0.45.4",
23401
+ version: "0.46.0",
23334
23402
  description: "Context layer for AI agents. Project context for Claude Code, Gemini CLI, and more.",
23335
23403
  main: "core/index.ts",
23336
23404
  bin: {
@@ -23359,6 +23427,7 @@ var require_package = __commonJS({
23359
23427
  validate: "bun scripts/validate-commands.js",
23360
23428
  lint: "biome lint .",
23361
23429
  "lint:fix": "biome lint --write .",
23430
+ "lint:meta": "bun core/cli/lint-meta-commentary.ts",
23362
23431
  format: "biome format --write .",
23363
23432
  "format:check": "biome format .",
23364
23433
  check: "biome check .",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prjct-cli",
3
- "version": "0.45.4",
3
+ "version": "0.46.0",
4
4
  "description": "Context layer for AI agents. Project context for Claude Code, Gemini CLI, and more.",
5
5
  "main": "core/index.ts",
6
6
  "bin": {
@@ -29,6 +29,7 @@
29
29
  "validate": "bun scripts/validate-commands.js",
30
30
  "lint": "biome lint .",
31
31
  "lint:fix": "biome lint --write .",
32
+ "lint:meta": "bun core/cli/lint-meta-commentary.ts",
32
33
  "format": "biome format --write .",
33
34
  "format:check": "biome format .",
34
35
  "check": "biome check .",