sentix 2.0.1

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,149 @@
1
+ /**
2
+ * sentix status — Governor 상태 + Memory Layer 요약 + 진화 단계 표시
3
+ */
4
+
5
+ import { registerCommand } from '../registry.js';
6
+ import { loadIndex } from '../lib/ticket-index.js';
7
+
8
+ registerCommand('status', {
9
+ description: 'Show Governor state and Memory Layer summary',
10
+ usage: 'sentix status',
11
+
12
+ async run(_args, ctx) {
13
+ ctx.log('=== Sentix Status ===\n');
14
+
15
+ // ── Governor State ──────────────────────────────
16
+ if (ctx.exists('tasks/governor-state.json')) {
17
+ try {
18
+ const state = await ctx.readJSON('tasks/governor-state.json');
19
+ ctx.log(`Governor: ${state.status || 'unknown'}`);
20
+ ctx.log(`Cycle: ${state.cycle_id || 'none'}`);
21
+ ctx.log(`Phase: ${state.current_phase || 'idle'}`);
22
+ ctx.log(`Request: "${state.request || ''}"`);
23
+
24
+ if (state.plan && state.plan.length > 0) {
25
+ ctx.log('\nPipeline:');
26
+ for (const step of state.plan) {
27
+ const icon = step.status === 'done' ? '✓'
28
+ : step.status === 'running' ? '▶'
29
+ : '○';
30
+ ctx.log(` ${icon} ${step.agent} — ${step.status}${step.result ? ` (${step.result})` : ''}`);
31
+ }
32
+ }
33
+
34
+ if (state.retries && Object.keys(state.retries).length > 0) {
35
+ ctx.log(`\nRetries: ${JSON.stringify(state.retries)}`);
36
+ }
37
+ } catch {
38
+ ctx.warn('Could not read governor-state.json');
39
+ }
40
+ } else {
41
+ ctx.log('Governor: idle (no active cycle)');
42
+ }
43
+
44
+ // ── Memory Layer ────────────────────────────────
45
+ ctx.log('\n--- Memory Layer ---\n');
46
+
47
+ // lessons.md
48
+ if (ctx.exists('tasks/lessons.md')) {
49
+ const lessons = await ctx.readFile('tasks/lessons.md');
50
+ const lines = lessons.split('\n').filter(l => l.startsWith('- '));
51
+ ctx.log(`Lessons: ${lines.length} entries`);
52
+ } else {
53
+ ctx.log('Lessons: (not initialized)');
54
+ }
55
+
56
+ // patterns.md
57
+ if (ctx.exists('tasks/patterns.md')) {
58
+ const patterns = await ctx.readFile('tasks/patterns.md');
59
+ const lines = patterns.split('\n').filter(l => l.startsWith('- '));
60
+ ctx.log(`Patterns: ${lines.length} entries`);
61
+ } else {
62
+ ctx.log('Patterns: (not initialized)');
63
+ }
64
+
65
+ // pattern-log.jsonl
66
+ if (ctx.exists('tasks/pattern-log.jsonl')) {
67
+ const log = await ctx.readFile('tasks/pattern-log.jsonl');
68
+ const entries = log.trim().split('\n').filter(Boolean);
69
+ ctx.log(`Pattern Log: ${entries.length} events`);
70
+ } else {
71
+ ctx.log('Pattern Log: (empty)');
72
+ }
73
+
74
+ // agent-metrics.jsonl
75
+ if (ctx.exists('tasks/agent-metrics.jsonl')) {
76
+ const metrics = await ctx.readFile('tasks/agent-metrics.jsonl');
77
+ const entries = metrics.trim().split('\n').filter(Boolean);
78
+ ctx.log(`Metrics: ${entries.length} records`);
79
+ } else {
80
+ ctx.log('Metrics: (empty)');
81
+ }
82
+
83
+ // ── Tickets ───────────────────────────────────────
84
+ ctx.log('\n--- Tickets ---\n');
85
+
86
+ try {
87
+ const tickets = await loadIndex(ctx);
88
+ if (tickets.length > 0) {
89
+ const byStatus = {};
90
+ for (const t of tickets) {
91
+ byStatus[t.status] = (byStatus[t.status] || 0) + 1;
92
+ }
93
+ const critical = tickets.filter(t => t.severity === 'critical' && t.status !== 'closed').length;
94
+ const parts = Object.entries(byStatus).map(([k, v]) => `${k}: ${v}`);
95
+ ctx.log(`Tickets: ${tickets.length} total (${parts.join(', ')})`);
96
+ if (critical > 0) {
97
+ ctx.warn(`Critical: ${critical} open critical ticket(s)`);
98
+ }
99
+ } else {
100
+ ctx.log('Tickets: (none)');
101
+ }
102
+ } catch {
103
+ ctx.log('Tickets: (not initialized)');
104
+ }
105
+
106
+ // ── Evolution Stage ─────────────────────────────
107
+ ctx.log('\n--- Evolution ---\n');
108
+
109
+ if (ctx.exists('.sentix/config.toml')) {
110
+ const config = await ctx.readFile('.sentix/config.toml');
111
+
112
+ const layers = [
113
+ { name: 'Core (Governor + Agents)', key: 'layers.core', required: true },
114
+ { name: 'Learning Pipeline', key: 'layers.learning' },
115
+ { name: 'Pattern Engine', key: 'layers.pattern_engine' },
116
+ { name: 'Visual Perception', key: 'layers.visual' },
117
+ { name: 'Self-Evolution', key: 'layers.evolution' },
118
+ ];
119
+
120
+ for (const layer of layers) {
121
+ const enabled = layer.required || isLayerEnabled(config, layer.key);
122
+ const icon = enabled ? '●' : '○';
123
+ ctx.log(` ${enabled ? icon : icon} ${layer.name}${enabled ? '' : ' (disabled)'}`);
124
+ }
125
+ } else {
126
+ ctx.warn('.sentix/config.toml not found. Run: sentix init');
127
+ }
128
+
129
+ ctx.log('');
130
+ },
131
+ });
132
+
133
+ /**
134
+ * Parse TOML config to check if a specific layer section has enabled = true.
135
+ * Handles per-section parsing instead of global string search.
136
+ */
137
+ function isLayerEnabled(config, sectionKey) {
138
+ const sectionHeader = `[${sectionKey}]`;
139
+ const idx = config.indexOf(sectionHeader);
140
+ if (idx === -1) return false;
141
+
142
+ // Extract content between this section header and the next section header
143
+ const afterSection = config.slice(idx + sectionHeader.length);
144
+ const nextSection = afterSection.indexOf('\n[');
145
+ const sectionContent = nextSection === -1 ? afterSection : afterSection.slice(0, nextSection);
146
+
147
+ // Look for enabled = true within this section only
148
+ return /enabled\s*=\s*true/.test(sectionContent);
149
+ }
@@ -0,0 +1,362 @@
1
+ /**
2
+ * sentix ticket — 버그/이슈 티켓 관리
3
+ *
4
+ * sentix ticket create "설명" [--severity critical|warning|suggestion]
5
+ * sentix ticket list [--status open] [--severity critical]
6
+ * sentix ticket debug <ticket-id>
7
+ */
8
+
9
+ import { spawnSync } from 'node:child_process';
10
+ import { registerCommand } from '../registry.js';
11
+ import {
12
+ loadIndex, addTicket, updateTicket, findTicket,
13
+ nextTicketId, classifySeverity, sortBySeverity, createTicketEntry,
14
+ } from '../lib/ticket-index.js';
15
+ import { findBestMatch } from '../lib/similarity.js';
16
+
17
+ registerCommand('ticket', {
18
+ description: 'Manage bug/issue tickets (create | list | debug)',
19
+ usage: 'sentix ticket <create|list|debug> [args...] [--severity critical|warning|suggestion]',
20
+
21
+ async run(args, ctx) {
22
+ const subcommand = args[0];
23
+
24
+ if (subcommand === 'create') {
25
+ await createTicket(args.slice(1), ctx);
26
+ } else if (!subcommand || subcommand === 'list') {
27
+ await listTickets(args.slice(1), ctx);
28
+ } else if (subcommand === 'debug') {
29
+ const ticketId = args[1];
30
+ if (!ticketId) {
31
+ ctx.error('Usage: sentix ticket debug <ticket-id>');
32
+ return;
33
+ }
34
+ await debugTicket(ticketId, ctx);
35
+ } else {
36
+ ctx.error(`Unknown subcommand: ${subcommand}`);
37
+ ctx.log('Usage: sentix ticket <create|list|debug> [args...]');
38
+ }
39
+ },
40
+ });
41
+
42
+ // ── sentix ticket create ──────────────────────────────
43
+
44
+ async function createTicket(args, ctx) {
45
+ // Parse --severity flag
46
+ let severity = null;
47
+ const flagIdx = args.indexOf('--severity');
48
+ if (flagIdx !== -1) {
49
+ severity = args[flagIdx + 1];
50
+ if (!['critical', 'warning', 'suggestion'].includes(severity)) {
51
+ ctx.error(`Invalid severity: ${severity} (use critical|warning|suggestion)`);
52
+ return;
53
+ }
54
+ args.splice(flagIdx, 2);
55
+ }
56
+
57
+ const description = args.join(' ').trim();
58
+ if (!description) {
59
+ ctx.error('Usage: sentix ticket create "bug description" [--severity critical|warning|suggestion]');
60
+ return;
61
+ }
62
+
63
+ // Auto-classify severity if not specified
64
+ if (!severity) {
65
+ severity = classifySeverity(description);
66
+ ctx.log(`Auto-classified severity: ${severity}`);
67
+ }
68
+
69
+ // Duplicate detection
70
+ await checkDuplicates(description, ctx);
71
+
72
+ // Create ticket
73
+ const id = await nextTicketId(ctx, 'bug');
74
+ const title = description.length > 80 ? description.slice(0, 77) + '...' : description;
75
+
76
+ const entry = createTicketEntry({
77
+ id,
78
+ type: 'bug',
79
+ title,
80
+ severity,
81
+ description,
82
+ });
83
+
84
+ // Generate markdown file
85
+ const md = `# ${id}: ${title}
86
+
87
+ - **Status:** open
88
+ - **Severity:** ${severity}
89
+ - **Created:** ${entry.created_at}
90
+ - **Related lessons:** ${await findRelatedLessons(description, ctx)}
91
+
92
+ ## Description
93
+
94
+ ${description}
95
+
96
+ ## Root Cause Analysis
97
+
98
+ <!-- Populated after sentix ticket debug -->
99
+
100
+ ## Resolution
101
+
102
+ <!-- Populated after fix -->
103
+ `;
104
+
105
+ await ctx.writeFile(entry.file_path, md);
106
+ await addTicket(ctx, entry);
107
+
108
+ // Log event
109
+ await ctx.appendJSONL('tasks/pattern-log.jsonl', {
110
+ ts: new Date().toISOString(),
111
+ event: 'ticket:create',
112
+ id,
113
+ severity,
114
+ title,
115
+ });
116
+
117
+ ctx.success(`Created ${id}: ${title}`);
118
+ ctx.log(` Severity: ${severity}`);
119
+ ctx.log(` File: ${entry.file_path}`);
120
+ }
121
+
122
+ // ── sentix ticket list ────────────────────────────────
123
+
124
+ async function listTickets(args, ctx) {
125
+ ctx.log('=== Tickets ===\n');
126
+
127
+ let entries = await loadIndex(ctx);
128
+
129
+ if (entries.length === 0) {
130
+ ctx.log(' (no tickets)');
131
+ ctx.log('\n Create one: sentix ticket create "description"');
132
+ return;
133
+ }
134
+
135
+ // Parse filters
136
+ const statusIdx = args.indexOf('--status');
137
+ if (statusIdx !== -1 && args[statusIdx + 1]) {
138
+ const status = args[statusIdx + 1];
139
+ entries = entries.filter(e => e.status === status);
140
+ }
141
+
142
+ const sevIdx = args.indexOf('--severity');
143
+ if (sevIdx !== -1 && args[sevIdx + 1]) {
144
+ const sev = args[sevIdx + 1];
145
+ entries = entries.filter(e => e.severity === sev);
146
+ }
147
+
148
+ entries = sortBySeverity(entries);
149
+
150
+ // Table header
151
+ ctx.log(` ${'ID'.padEnd(12)} ${'SEVERITY'.padEnd(12)} ${'STATUS'.padEnd(14)} TITLE`);
152
+ ctx.log(` ${'─'.repeat(12)} ${'─'.repeat(12)} ${'─'.repeat(14)} ${'─'.repeat(30)}`);
153
+
154
+ for (const e of entries) {
155
+ const sev = e.severity ? e.severity.padEnd(12) : '-'.padEnd(12);
156
+ ctx.log(` ${e.id.padEnd(12)} ${sev} ${e.status.padEnd(14)} ${e.title}`);
157
+ }
158
+
159
+ ctx.log(`\n Total: ${entries.length} ticket(s)`);
160
+ }
161
+
162
+ // ── sentix ticket debug ───────────────────────────────
163
+
164
+ async function debugTicket(ticketId, ctx) {
165
+ // 1. Find ticket
166
+ const ticket = await findTicket(ctx, ticketId);
167
+ if (!ticket) {
168
+ ctx.error(`Ticket not found: ${ticketId}`);
169
+ return;
170
+ }
171
+
172
+ if (ticket.status !== 'open' && ticket.status !== 'in_progress') {
173
+ ctx.error(`Ticket ${ticketId} is ${ticket.status} — only open or in_progress tickets can be debugged`);
174
+ return;
175
+ }
176
+
177
+ // 2. Check Claude Code
178
+ const claudeCheck = spawnSync('claude', ['--version'], { encoding: 'utf-8', stdio: 'pipe' });
179
+ if (claudeCheck.error) {
180
+ ctx.error('Claude Code CLI not found. Install: https://docs.anthropic.com/en/docs/claude-code');
181
+ return;
182
+ }
183
+
184
+ // 3. Check for concurrent pipeline
185
+ if (ctx.exists('tasks/governor-state.json')) {
186
+ try {
187
+ const existing = await ctx.readJSON('tasks/governor-state.json');
188
+ if (existing.status === 'in_progress') {
189
+ ctx.error(`Another pipeline is running: ${existing.cycle_id}`);
190
+ return;
191
+ }
192
+ } catch { /* safe to proceed */ }
193
+ }
194
+
195
+ // 4. Update ticket status
196
+ await updateTicket(ctx, ticketId, { status: 'in_progress' });
197
+ ctx.log(`Debugging ${ticketId}: ${ticket.title}`);
198
+ ctx.log(`Severity: ${ticket.severity}\n`);
199
+
200
+ // 5. Read ticket markdown
201
+ let ticketContent = '';
202
+ if (ctx.exists(ticket.file_path)) {
203
+ ticketContent = await ctx.readFile(ticket.file_path);
204
+ }
205
+
206
+ // 6. Read lessons for context
207
+ let lessons = '';
208
+ if (ctx.exists('tasks/lessons.md')) {
209
+ lessons = await ctx.readFile('tasks/lessons.md');
210
+ }
211
+
212
+ // 7. Determine retry limit by severity
213
+ const retryLimits = { critical: 3, warning: 10, suggestion: 0 };
214
+ const retryLimit = retryLimits[ticket.severity] || 3;
215
+
216
+ // 8. Create governor state
217
+ const cycleId = `debug-${ticketId}-${String(Date.now()).slice(-3)}`;
218
+ const state = {
219
+ schema_version: 1,
220
+ cycle_id: cycleId,
221
+ request: `DEBUG: ${ticket.title}`,
222
+ status: 'in_progress',
223
+ current_phase: 'dev-fix',
224
+ plan: [{ agent: 'dev-fix', status: 'running', result_ref: null }],
225
+ retries: { 'dev-fix': 0 },
226
+ cross_judgments: [],
227
+ started_at: new Date().toISOString(),
228
+ completed_at: null,
229
+ human_intervention_requested: false,
230
+ ticket_id: ticketId,
231
+ ticket_type: 'bug',
232
+ };
233
+ await ctx.writeJSON('tasks/governor-state.json', state);
234
+
235
+ // 9. Log event
236
+ await ctx.appendJSONL('tasks/pattern-log.jsonl', {
237
+ ts: new Date().toISOString(),
238
+ event: 'ticket:debug',
239
+ id: ticketId,
240
+ severity: ticket.severity,
241
+ cycle_id: cycleId,
242
+ });
243
+
244
+ // 10. Invoke Claude Code with debug prompt
245
+ const prompt = [
246
+ 'Read CLAUDE.md and FRAMEWORK.md first.',
247
+ '',
248
+ `DEBUG MODE — Ticket: ${ticketId}`,
249
+ '',
250
+ '## Ticket Content',
251
+ ticketContent,
252
+ '',
253
+ '## Known Lessons',
254
+ lessons.slice(0, 2000),
255
+ '',
256
+ '## Instructions',
257
+ '1. Analyze the bug described in the ticket',
258
+ '2. Identify root cause',
259
+ '3. Implement fix within SCOPE (respect hard rules)',
260
+ '4. Run tests to verify fix',
261
+ '5. Generate LESSON_LEARNED and append to tasks/lessons.md',
262
+ `6. Update ${ticket.file_path} with root cause analysis`,
263
+ '7. Update tasks/governor-state.json at each phase',
264
+ '',
265
+ `Severity: ${ticket.severity} (retry limit: ${retryLimit})`,
266
+ ].join('\n');
267
+
268
+ ctx.log('Invoking Claude Code for debugging...\n');
269
+
270
+ const result = spawnSync('claude', ['-p', prompt], {
271
+ cwd: ctx.cwd,
272
+ stdio: 'inherit',
273
+ timeout: 600_000,
274
+ });
275
+
276
+ // 11. Handle result
277
+ if (result.error || result.status !== 0) {
278
+ state.status = 'failed';
279
+ state.error = result.error?.message || `Exit code ${result.status}`;
280
+ await ctx.writeJSON('tasks/governor-state.json', state);
281
+
282
+ await ctx.appendJSONL('tasks/pattern-log.jsonl', {
283
+ ts: new Date().toISOString(),
284
+ event: 'ticket:debug:failed',
285
+ id: ticketId,
286
+ cycle_id: cycleId,
287
+ error: state.error,
288
+ });
289
+
290
+ // Escalate critical failures to roadmap
291
+ if (ticket.severity === 'critical') {
292
+ ctx.warn('Critical ticket debug failed — escalating to roadmap');
293
+ if (ctx.exists('tasks/roadmap.md')) {
294
+ const roadmap = await ctx.readFile('tasks/roadmap.md');
295
+ const escalation = `\n- **[ESCALATED]** ${ticketId}: ${ticket.title} (debug failed, needs manual review)\n`;
296
+ await ctx.writeFile('tasks/roadmap.md', roadmap + escalation);
297
+ }
298
+ }
299
+
300
+ await updateTicket(ctx, ticketId, { status: 'open' });
301
+ ctx.error(`Debug failed for ${ticketId}`);
302
+ return;
303
+ }
304
+
305
+ // 12. Success
306
+ state.status = 'completed';
307
+ state.completed_at = new Date().toISOString();
308
+ state.plan[0].status = 'done';
309
+ await ctx.writeJSON('tasks/governor-state.json', state);
310
+
311
+ await updateTicket(ctx, ticketId, {
312
+ status: 'review',
313
+ related_cycle: cycleId,
314
+ });
315
+
316
+ await ctx.appendJSONL('tasks/pattern-log.jsonl', {
317
+ ts: new Date().toISOString(),
318
+ event: 'ticket:debug:complete',
319
+ id: ticketId,
320
+ cycle_id: cycleId,
321
+ });
322
+
323
+ ctx.success(`Debug completed for ${ticketId} — status: review`);
324
+ }
325
+
326
+ // ── Helpers ───────────────────────────────────────────
327
+
328
+ async function checkDuplicates(description, ctx) {
329
+ const candidates = [];
330
+
331
+ // Collect from lessons.md
332
+ if (ctx.exists('tasks/lessons.md')) {
333
+ const lessons = await ctx.readFile('tasks/lessons.md');
334
+ const lines = lessons.split('\n').filter(l => l.startsWith('- '));
335
+ for (const line of lines) {
336
+ candidates.push(line.replace(/^-\s*/, ''));
337
+ }
338
+ }
339
+
340
+ // Collect from existing tickets
341
+ const entries = await loadIndex(ctx);
342
+ for (const e of entries) {
343
+ candidates.push(e.title);
344
+ }
345
+
346
+ const match = findBestMatch(description, candidates);
347
+ if (match) {
348
+ ctx.warn(`Possible duplicate (${(match.score * 100).toFixed(0)}% similar):`);
349
+ ctx.warn(` "${match.text}"`);
350
+ }
351
+ }
352
+
353
+ async function findRelatedLessons(description, ctx) {
354
+ if (!ctx.exists('tasks/lessons.md')) return '(none)';
355
+
356
+ const lessons = await ctx.readFile('tasks/lessons.md');
357
+ const lines = lessons.split('\n').filter(l => l.startsWith('- '));
358
+
359
+ const match = findBestMatch(description, lines.map(l => l.replace(/^-\s*/, '')));
360
+ if (match) return match.text;
361
+ return '(none)';
362
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * sentix update — 프레임워크 파일을 최신 버전으로 동기화
3
+ *
4
+ * sentix 원본 패키지에서 프레임워크 공통 파일을 가져와 로컬 프로젝트를 업데이트한다.
5
+ * 프로젝트 고유 파일(CLAUDE.md, .sentix/config.toml, providers/, env-profiles/)은 건드리지 않는다.
6
+ *
7
+ * 사용법:
8
+ * sentix update # 실제 업데이트
9
+ * sentix update --dry # 변경 사항만 미리 확인
10
+ */
11
+
12
+ import { registerCommand } from '../registry.js';
13
+ import { VERSION } from '../version.js';
14
+ import { readFileSync, existsSync } from 'node:fs';
15
+ import { resolve, dirname } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+ const sentixRoot = resolve(__dirname, '..', '..');
20
+
21
+ // 동기화 대상: 프레임워크 공통 파일 (모든 프로젝트가 동일해야 하는 것)
22
+ const SYNC_FILES = [
23
+ { src: '.github/workflows/deploy.yml', dst: '.github/workflows/deploy.yml' },
24
+ { src: '.github/workflows/security-scan.yml', dst: '.github/workflows/security-scan.yml' },
25
+ { src: '.sentix/rules/hard-rules.md', dst: '.sentix/rules/hard-rules.md' },
26
+ { src: 'FRAMEWORK.md', dst: 'FRAMEWORK.md' },
27
+ { src: 'docs/governor-sop.md', dst: 'docs/governor-sop.md' },
28
+ { src: 'docs/agent-scopes.md', dst: 'docs/agent-scopes.md' },
29
+ { src: 'docs/severity.md', dst: 'docs/severity.md' },
30
+ { src: 'docs/architecture.md', dst: 'docs/architecture.md' },
31
+ ];
32
+
33
+ registerCommand('update', {
34
+ description: 'Update framework files to the latest sentix version',
35
+ usage: 'sentix update [--dry]',
36
+
37
+ async run(args, ctx) {
38
+ const dryRun = args.includes('--dry');
39
+
40
+ ctx.log(`sentix update v${VERSION}`);
41
+ ctx.log(`source: ${sentixRoot}`);
42
+ ctx.log(`target: ${ctx.cwd}`);
43
+ if (dryRun) ctx.warn('DRY RUN — no files will be changed\n');
44
+ else ctx.log('');
45
+
46
+ // sentix 원본에서 실행 중인지 확인
47
+ if (resolve(ctx.cwd) === resolve(sentixRoot)) {
48
+ ctx.error('Cannot update sentix itself. Run this from a downstream project.');
49
+ return;
50
+ }
51
+
52
+ // sentix가 초기화된 프로젝트인지 확인
53
+ if (!ctx.exists('.sentix/config.toml') && !ctx.exists('CLAUDE.md')) {
54
+ ctx.error('This project has not been initialized with sentix.');
55
+ ctx.log('Run: sentix init');
56
+ return;
57
+ }
58
+
59
+ const results = { updated: [], created: [], skipped: [], unchanged: [] };
60
+
61
+ for (const { src, dst } of SYNC_FILES) {
62
+ const srcPath = resolve(sentixRoot, src);
63
+
64
+ // 원본 파일이 없으면 스킵
65
+ if (!existsSync(srcPath)) {
66
+ results.skipped.push({ file: dst, reason: 'source not found' });
67
+ continue;
68
+ }
69
+
70
+ const srcContent = readFileSync(srcPath, 'utf-8');
71
+
72
+ if (ctx.exists(dst)) {
73
+ const dstContent = await ctx.readFile(dst);
74
+
75
+ if (srcContent === dstContent) {
76
+ results.unchanged.push(dst);
77
+ continue;
78
+ }
79
+
80
+ // diff 요약 생성
81
+ const srcLines = srcContent.split('\n');
82
+ const dstLines = dstContent.split('\n');
83
+ const added = srcLines.length - dstLines.length;
84
+
85
+ ctx.log(`${dryRun ? '[DRY] ' : ''}Updating: ${dst}`);
86
+ ctx.log(` ${dstLines.length} lines → ${srcLines.length} lines (${added >= 0 ? '+' : ''}${added})`);
87
+
88
+ // 주요 변경 내용 표시 (새로 추가된 라인 중 의미 있는 것)
89
+ const dstSet = new Set(dstLines.map(l => l.trim()));
90
+ const newLines = srcLines
91
+ .filter(l => l.trim() && !l.trim().startsWith('#') && !dstSet.has(l.trim()))
92
+ .slice(0, 5);
93
+ if (newLines.length > 0) {
94
+ ctx.log(' New:');
95
+ for (const line of newLines) {
96
+ ctx.log(` + ${line.trim().substring(0, 80)}`);
97
+ }
98
+ }
99
+
100
+ if (!dryRun) {
101
+ await ctx.writeFile(dst, srcContent);
102
+ ctx.success(`Updated: ${dst}`);
103
+ }
104
+ results.updated.push(dst);
105
+ } else {
106
+ ctx.log(`${dryRun ? '[DRY] ' : ''}Creating: ${dst}`);
107
+ if (!dryRun) {
108
+ await ctx.writeFile(dst, srcContent);
109
+ ctx.success(`Created: ${dst}`);
110
+ }
111
+ results.created.push(dst);
112
+ }
113
+ }
114
+
115
+ // 요약
116
+ ctx.log('\n=== Update Summary ===');
117
+ if (results.updated.length > 0) {
118
+ ctx.log(`Updated: ${results.updated.length} file(s)`);
119
+ for (const f of results.updated) ctx.log(` ${f}`);
120
+ }
121
+ if (results.created.length > 0) {
122
+ ctx.log(`Created: ${results.created.length} file(s)`);
123
+ for (const f of results.created) ctx.log(` ${f}`);
124
+ }
125
+ if (results.unchanged.length > 0) {
126
+ ctx.log(`Unchanged: ${results.unchanged.length} file(s)`);
127
+ }
128
+ if (results.skipped.length > 0) {
129
+ ctx.warn(`Skipped: ${results.skipped.length} file(s)`);
130
+ for (const s of results.skipped) ctx.log(` ${s.file} (${s.reason})`);
131
+ }
132
+
133
+ const totalChanges = results.updated.length + results.created.length;
134
+ if (totalChanges === 0) {
135
+ ctx.success('\nAlready up to date.');
136
+ } else if (dryRun) {
137
+ ctx.warn(`\n${totalChanges} file(s) would be changed. Run without --dry to apply.`);
138
+ } else {
139
+ ctx.success(`\n${totalChanges} file(s) updated to sentix v${VERSION}.`);
140
+ ctx.log('Run: sentix doctor — to verify project health');
141
+ }
142
+ },
143
+ });