docuking-mcp 3.0.0 → 3.6.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/handlers/sync.js CHANGED
@@ -46,6 +46,12 @@ import {
46
46
  updateIndexAfterSync,
47
47
  computeFileHash,
48
48
  getIndexStats,
49
+ // v2 추가
50
+ markAsDeleted,
51
+ wasDeletedLocally,
52
+ clearDeletedEntry,
53
+ classifyForPush,
54
+ classifyForPull,
49
55
  } from '../lib/index-cache.js';
50
56
 
51
57
  // 레포지토리 매핑 (메모리에 캐시)
@@ -74,6 +80,18 @@ docuking_init 호출 시 apiKey 파라미터를 포함해주세요.`,
74
80
  // Co-worker 권한은 API Key 형식에서 판단
75
81
  const { isCoworker, coworkerFolder } = parseCoworkerFromApiKey(apiKey);
76
82
 
83
+ // 기존 config 확인 - 프로젝트 ID가 변경되면 인덱스 초기화
84
+ const existingConfig = getLocalConfig(localPath);
85
+ if (existingConfig && existingConfig.projectId && existingConfig.projectId !== projectId) {
86
+ // 프로젝트 ID가 변경됨 - 인덱스 파일 삭제
87
+ const indexPath = path.join(localPath, '.docuking', 'index.json');
88
+ if (fs.existsSync(indexPath)) {
89
+ fs.unlinkSync(indexPath);
90
+ console.error(`[DocuKing] 프로젝트 ID 변경 감지: ${existingConfig.projectId} → ${projectId}`);
91
+ console.error(`[DocuKing] 기존 인덱스 초기화됨`);
92
+ }
93
+ }
94
+
77
95
  // .docuking/config.json에 설정 저장
78
96
  saveLocalConfig(localPath, {
79
97
  projectId,
@@ -84,7 +102,7 @@ docuking_init 호출 시 apiKey 파라미터를 포함해주세요.`,
84
102
  createdAt: new Date().toISOString(),
85
103
  });
86
104
 
87
- // 폴더 생성: 오너는 yy_All_Docu/, 협업자는 yy_Coworker_{폴더명}/ (별도)
105
+ // 폴더 생성: 오너는 yy_All_Docu/, 협업자는 zz_Coworker_{폴더명}/ (별도)
88
106
  const mainFolderName = 'yy_All_Docu';
89
107
  const mainFolderPath = path.join(localPath, mainFolderName);
90
108
 
@@ -96,9 +114,10 @@ docuking_init 호출 시 apiKey 파라미터를 포함해주세요.`,
96
114
  let coworkerFolderName = null;
97
115
  let coworkerFolderPath = null;
98
116
 
99
- // AI 폴더 (오너: xy_TalkTodoPlan, 협업자: zz_TalkTodoPlan)
117
+ // AI 폴더 (오너: xy_TalkTodoPlan, 협업자: {폴더명}_TalkTodoPlan)
100
118
  const ownerAiFolder = 'xy_TalkTodoPlan';
101
- const coworkerAiFolder = 'zz_TalkTodoPlan';
119
+ // 협업자 AI 폴더는 동적으로 생성 (coworkerFolder가 있을 때만)
120
+ const coworkerAiFolder = coworkerFolder ? `${coworkerFolder}_TalkTodoPlan` : null;
102
121
 
103
122
  // xx_ 시스템 폴더 목록 (오너 전용)
104
123
  const systemFolders = ['xx_Infra_Config', 'xx_Policy'];
@@ -110,8 +129,8 @@ docuking_init 호출 시 apiKey 파라미터를 포함해주세요.`,
110
129
  }
111
130
 
112
131
  if (isCoworker) {
113
- // 협업자: yy_Coworker_{폴더명}/ 폴더를 yy_All_Docu/ 밖에 별도 생성
114
- coworkerFolderName = `yy_Coworker_${coworkerFolder}`;
132
+ // 협업자: zz_Coworker_{폴더명}/ 폴더를 yy_All_Docu/ 밖에 별도 생성
133
+ coworkerFolderName = `zz_Coworker_${coworkerFolder}`;
115
134
  coworkerFolderPath = path.join(localPath, coworkerFolderName);
116
135
  if (!fs.existsSync(coworkerFolderPath)) {
117
136
  fs.mkdirSync(coworkerFolderPath, { recursive: true });
@@ -121,7 +140,7 @@ docuking_init 호출 시 apiKey 파라미터를 포함해주세요.`,
121
140
  if (!fs.existsSync(coworkerPrivatePath)) {
122
141
  fs.mkdirSync(coworkerPrivatePath, { recursive: true });
123
142
  }
124
- // 협업자 폴더 안에 zz_TalkTodoPlan 폴더 생성
143
+ // 협업자 폴더 안에 {폴더명}_TalkTodoPlan 폴더 생성
125
144
  const coworkerAiFolderPath = path.join(coworkerFolderPath, coworkerAiFolder);
126
145
  if (!fs.existsSync(coworkerAiFolderPath)) {
127
146
  fs.mkdirSync(coworkerAiFolderPath, { recursive: true });
@@ -244,11 +263,11 @@ Git처럼 무엇을 변경했는지 명확히 작성해주세요.
244
263
 
245
264
  // Co-worker 권한은 API Key 형식에서 판단
246
265
  const { isCoworker, coworkerFolder } = parseCoworkerFromApiKey(apiKey);
247
- const coworkerFolderName = isCoworker ? `yy_Coworker_${coworkerFolder}` : null;
266
+ const coworkerFolderName = isCoworker ? `zz_Coworker_${coworkerFolder}` : null;
248
267
 
249
268
  // 작업 폴더 결정: 프로젝트 루트 기준 절대경로 사용
250
- // 오너: yy_All_Docu/, zz_ai_*/ 폴더들 Push
251
- // 협업자: yy_Coworker_{폴더명}/ 폴더만 Push
269
+ // 오너: xx_*/ + xy_*/ + yy_All_Docu/ 폴더들 Push
270
+ // 협업자: zz_Coworker_{폴더명}/ 폴더만 Push
252
271
  const mainFolderPath = path.join(localPath, 'yy_All_Docu');
253
272
 
254
273
  // Push 대상 폴더 목록 (프로젝트 루트 기준)
@@ -258,7 +277,7 @@ Git처럼 무엇을 변경했는지 명확히 작성해주세요.
258
277
  console.error(`[DocuKing] Push 권한: isCoworker=${isCoworker}, coworkerFolder=${coworkerFolder}, coworkerFolderName=${coworkerFolderName}`);
259
278
 
260
279
  if (isCoworker) {
261
- // 협업자: yy_Coworker_{폴더명}/ 폴더 + xx_Urgent/ 폴더 Push
280
+ // 협업자: zz_Coworker_{폴더명}/ 폴더 + xx_Urgent/ 폴더 Push
262
281
  const coworkerPath = path.join(localPath, coworkerFolderName);
263
282
  console.error(`[DocuKing] 협업자 Push 대상 폴더: ${coworkerPath}`);
264
283
  if (!fs.existsSync(coworkerPath)) {
@@ -294,13 +313,17 @@ docuking_init을 먼저 실행하세요.`,
294
313
  };
295
314
  }
296
315
 
297
- // 오너 Push 대상: xx_*/ + yy_All_Docu/ + zz_ai_*/ 폴더들
316
+ // 오너 Push 대상: xx_*/ + xy_*/ + yy_All_Docu/ 폴더들
298
317
  pushTargetFolders.push({ localPath: mainFolderPath, serverPrefix: 'yy_All_Docu' });
299
318
 
300
- // xx_*, zz_ai_* 폴더들 찾기
319
+ // 특수폴더 패턴으로 Push 대상 폴더 찾기
320
+ // 특수폴더: xx_*, xy_TalkTodoPlan, yy_All_Docu, zz_Coworker_*
301
321
  const rootEntries = fs.readdirSync(localPath, { withFileTypes: true });
302
322
  for (const entry of rootEntries) {
303
- if (entry.isDirectory() && (entry.name.startsWith('xx_') || entry.name.startsWith('zz_ai_'))) {
323
+ if (entry.isDirectory() && (
324
+ entry.name.startsWith('xx_') ||
325
+ entry.name === 'xy_TalkTodoPlan'
326
+ )) {
304
327
  const folderPath = path.join(localPath, entry.name);
305
328
  pushTargetFolders.push({ localPath: folderPath, serverPrefix: entry.name });
306
329
  }
@@ -482,33 +505,13 @@ docuking_init을 먼저 실행하세요.`,
482
505
  }
483
506
 
484
507
  // ========================================
485
- // 로컬 파일 경로 목록 저장 (Pull 전에!)
486
- // soft-delete에서 "로컬에 없는 파일" 판단에 사용
487
- // Pull이 파일을 내려받아도 이 목록은 변하지 않음
508
+ // Pull 선행 제거 (2026-01-24)
488
509
  // ========================================
489
- const localPathsBeforePull = new Set(filesToPush.map(f => f.serverPath));
490
- console.error(`[DocuKing] Pull 로컬 파일 ${localPathsBeforePull.size}개 기록됨`);
491
-
492
- // ========================================
493
- // Pull 실행 (git pull && git push 패턴)
510
+ // 이전: Push 전에 Pull 실행 (git pull && git push 패턴)
511
+ // 문제: Pull 로컬 변경사항(삭제, 수정)을 덮어씀 → 좀비 파일 부활
512
+ // 해결: Push는 Push만. Pull은 사용자가 별도로 호출.
494
513
  // ========================================
495
- let pullResultText = '';
496
- try {
497
- const pullResult = await handlePullInternal({ localPath, filePath });
498
- pullResultText = pullResult.text;
499
- } catch (e) {
500
- return {
501
- content: [
502
- {
503
- type: 'text',
504
- text: `❌ Pull 실패로 Push를 중단합니다.
505
- 오류: ${e.message}
506
-
507
- 먼저 Pull 문제를 해결한 후 다시 시도하세요.`,
508
- },
509
- ],
510
- };
511
- }
514
+ console.error(`[DocuKing] Push 시작 (Pull 선행 없음 - 로컬 우선)`);
512
515
 
513
516
  if (filesToPush.length === 0 && emptyFolders.length === 0) {
514
517
  return {
@@ -724,7 +727,7 @@ docuking_init을 먼저 실행하세요.`,
724
727
  },
725
728
  body: JSON.stringify({
726
729
  projectId,
727
- path: file.serverPath, // 서버 경로 (코워커는 yy_Coworker_{폴더명}/파일경로)
730
+ path: file.serverPath, // 서버 경로 (코워커는 zz_Coworker_{폴더명}/파일경로)
728
731
  content,
729
732
  encoding, // 'utf-8' 또는 'base64'
730
733
  message, // 커밋 메시지
@@ -803,23 +806,122 @@ docuking_init을 먼저 실행하세요.`,
803
806
  }
804
807
 
805
808
  // ========================================
806
- // 4. 자동 삭제 비활성화 (2026-01-17 버그 수정)
809
+ // 4. 인덱스 기반 삭제 전파 (v2: 2026-01-24 강화)
807
810
  // ========================================
808
- // 이전 로직: "서버에 있고 로컬에 없으면 삭제"
809
- // 문제: 다른 프로젝트에서 Pull하면 서버 파일이 삭제됨
810
- // 해결: 자동 삭제 제거, docuking_delete로만 명시적 삭제
811
- //
812
- // 삭제는 명시적으로만:
813
- // - 사용자가 "이 파일 삭제해줘" 요청 시
814
- // - docuking_delete MCP 도구 사용
811
+ // 3-way 비교:
812
+ // - 인덱스에 있음 + 로컬에 없음 + 서버에 있음 = 로컬 삭제 → 서버도 삭제
813
+ // - 인덱스에 있음 + 로컬에 없음 + 서버 수정됨 = 충돌 (로컬 삭제 vs 서버 수정)
814
+ // - 인덱스에 없음 + 로컬에 없음 + 서버에 있음 = 다른 곳에서 생성 → 무시
815
+ // - source: 'web' 파일은 삭제 보호
815
816
  // ========================================
816
817
  let deleted = 0;
817
818
  const deletedFilePaths = [];
818
819
  let protectedFiles = [];
820
+ const conflicts = [];
821
+
822
+ // 인덱스에서 삭제된 파일 찾기 (인덱스에 있었는데 로컬에 없는 파일)
823
+ const locallyDeletedFiles = [];
824
+ for (const indexPath of Object.keys(localIndex.files)) {
825
+ // Push 대상 폴더 내의 파일인지 확인
826
+ let isInPushTarget = false;
827
+ let fullPath = '';
828
+
829
+ for (const target of pushTargetFolders) {
830
+ if (indexPath.startsWith(target.serverPrefix)) {
831
+ isInPushTarget = true;
832
+ const relativePath = indexPath.slice(target.serverPrefix.length + 1);
833
+ fullPath = path.join(target.localPath, relativePath);
834
+ break;
835
+ }
836
+ }
837
+
838
+ if (!isInPushTarget) continue;
839
+
840
+ // 로컬에 파일이 없으면 = 삭제된 파일
841
+ if (!fs.existsSync(fullPath)) {
842
+ const serverMeta = serverPathToMeta[indexPath];
843
+ const indexEntry = localIndex.files[indexPath];
819
844
 
820
- // 자동 삭제 로직 비활성화 - 아래 코드는 주석 처리
821
- // "로컬에 없으면 삭제" 로직은 위험하므로 제거
822
- console.error(`[DocuKing] 자동 삭제 비활성화됨 (명시적 삭제만 허용: docuking_delete 사용)`);
845
+ // 서버에 없으면 이미 삭제됨 스킵
846
+ if (!serverMeta) {
847
+ console.error(`[DocuKing] 이미 삭제됨: ${indexPath}`);
848
+ // deletedFiles 기록 정리
849
+ clearDeletedEntry(localIndex, indexPath);
850
+ continue;
851
+ }
852
+
853
+ // source: 'web' 파일은 삭제 보호
854
+ if (serverMeta.source === 'web') {
855
+ console.error(`[DocuKing] 삭제 보호 (source:web): ${indexPath}`);
856
+ protectedFiles.push(indexPath);
857
+ continue;
858
+ }
859
+
860
+ // 서버가 수정되었는지 확인 (충돌 감지)
861
+ const serverModified = serverMeta.hash !== (indexEntry.serverHash || indexEntry.hash);
862
+ if (serverModified) {
863
+ console.error(`[DocuKing] ⚠️ 충돌: ${indexPath} (로컬 삭제 vs 서버 수정)`);
864
+ conflicts.push({
865
+ path: indexPath,
866
+ type: 'delete-vs-modify',
867
+ localAction: 'delete',
868
+ serverHash: serverMeta.hash,
869
+ });
870
+ // 충돌 시에도 로컬 우선 정책으로 삭제 진행 (경고만 출력)
871
+ }
872
+
873
+ locallyDeletedFiles.push(indexPath);
874
+ console.error(`[DocuKing] 삭제 감지: ${indexPath}`);
875
+ }
876
+ }
877
+
878
+ // 삭제된 파일을 서버에서도 삭제
879
+ if (locallyDeletedFiles.length > 0) {
880
+ console.error(`[DocuKing] 서버에서 ${locallyDeletedFiles.length}개 파일 삭제 요청`);
881
+ try {
882
+ const deleteResponse = await fetch(`${API_ENDPOINT}/files/soft-delete`, {
883
+ method: 'POST',
884
+ headers: {
885
+ 'Content-Type': 'application/json',
886
+ 'Authorization': `Bearer ${apiKey}`,
887
+ },
888
+ body: JSON.stringify({
889
+ projectId,
890
+ paths: locallyDeletedFiles,
891
+ }),
892
+ });
893
+
894
+ if (deleteResponse.ok) {
895
+ const deleteResult = await deleteResponse.json();
896
+ deleted = deleteResult.deleted || 0;
897
+ deletedFilePaths.push(...(deleteResult.deletedPaths || []));
898
+
899
+ // 인덱스에서 삭제 처리 (v2: markAsDeleted 사용)
900
+ for (const deletedPath of locallyDeletedFiles) {
901
+ const entry = localIndex.files[deletedPath];
902
+ markAsDeleted(localIndex, deletedPath, entry?.hash);
903
+ }
904
+
905
+ console.error(`[DocuKing] 서버 삭제 완료: ${deleted}개`);
906
+ if (protectedFiles.length > 0) {
907
+ console.error(`[DocuKing] 보호된 파일 (source:web): ${protectedFiles.join(', ')}`);
908
+ }
909
+ } else {
910
+ console.error(`[DocuKing] 서버 삭제 실패: ${await deleteResponse.text()}`);
911
+ }
912
+ } catch (e) {
913
+ console.error(`[DocuKing] 서버 삭제 요청 오류: ${e.message}`);
914
+ }
915
+ }
916
+
917
+ // 충돌 경고 출력
918
+ if (conflicts.length > 0) {
919
+ console.error(`\n[DocuKing] ⚠️ ${conflicts.length}개 충돌 발생 (로컬 우선 정책으로 처리됨):`);
920
+ for (const c of conflicts) {
921
+ console.error(` - ${c.path}: ${c.type}`);
922
+ }
923
+ console.error('');
924
+ }
823
925
 
824
926
  // 5. 빈 폴더 생성
825
927
  let createdEmptyFolders = 0;
@@ -851,12 +953,13 @@ docuking_init을 먼저 실행하세요.`,
851
953
  }
852
954
 
853
955
  // ========================================
854
- // 로컬 인덱스 업데이트 및 저장
956
+ // 로컬 인덱스 업데이트 및 저장 (v2)
855
957
  // ========================================
856
- if (syncedFiles.length > 0) {
857
- updateIndexAfterSync(localIndex, syncedFiles, deletedFilePaths);
958
+ if (syncedFiles.length > 0 || deletedFilePaths.length > 0) {
959
+ // syncType: 'push'로 lastPush 타임스탬프 업데이트
960
+ updateIndexAfterSync(localIndex, syncedFiles, deletedFilePaths, { syncType: 'push' });
858
961
  if (saveIndex(localPath, localIndex)) {
859
- console.error(`[DocuKing] 로컬 인덱스 업데이트: ${syncedFiles.length}개 파일`);
962
+ console.error(`[DocuKing] 로컬 인덱스 업데이트: ${syncedFiles.length}개 동기화, ${deletedFilePaths.length}개 삭제`);
860
963
  }
861
964
  }
862
965
 
@@ -948,29 +1051,13 @@ docuking_init을 먼저 실행하세요.`,
948
1051
  }
949
1052
 
950
1053
  resultText += `\n\n🌐 웹 탐색기에서 커밋 히스토리를 확인할 수 있습니다: https://docuking.ai`;
951
-
952
- // Pull 결과가 있으면 앞에 추가
953
- let finalText = '';
954
- if (pullResultText) {
955
- // Pull에서 실제로 받은 파일이 있는 경우만 표시
956
- const pullHasChanges = pullResultText.includes('다운로드 (신규):') && !pullResultText.includes('다운로드 (신규): 0개');
957
- const pullHasUpdates = pullResultText.includes('업데이트 (변경됨):') && !pullResultText.includes('업데이트 (변경됨): 0개');
958
-
959
- if (pullHasChanges || pullHasUpdates) {
960
- finalText = `📥 [1단계] Pull (서버 → 로컬)\n${pullResultText}\n\n${'─'.repeat(50)}\n\n📤 [2단계] Push (로컬 → 서버)\n${resultText}`;
961
- } else {
962
- // Pull에서 변경 없으면 간단히 표시
963
- finalText = `📥 Pull: 변경 없음 (최신 상태)\n\n📤 Push:\n${resultText}`;
964
- }
965
- } else {
966
- finalText = resultText;
967
- }
1054
+ resultText += `\n\n💡 서버에서 파일을 받으려면 별도로 Pull을 실행하세요.`;
968
1055
 
969
1056
  return {
970
1057
  content: [
971
1058
  {
972
1059
  type: 'text',
973
- text: finalText,
1060
+ text: resultText,
974
1061
  },
975
1062
  ],
976
1063
  };
@@ -1012,8 +1099,8 @@ export async function handlePullInternal(args) {
1012
1099
  // 오너 전용 폴더
1013
1100
  const ownerFolders = ['xx_Infra_Config', 'xx_Policy', 'xy_TalkTodoPlan'];
1014
1101
 
1015
- // 협업자 전용 폴더 (yy_Coworker_{폴더명}/ 안에)
1016
- const coworkerSubFolders = ['zz_TalkTodoPlan', '_Private'];
1102
+ // 협업자 전용 폴더 (zz_Coworker_{폴더명}/ 안에)
1103
+ // AI 폴더는 {폴더명}_TalkTodoPlan 형식 (coworkerFolder 기반 동적 생성)
1017
1104
 
1018
1105
  // 공통 폴더 체크
1019
1106
  for (const folder of commonFolders) {
@@ -1026,8 +1113,9 @@ export async function handlePullInternal(args) {
1026
1113
  }
1027
1114
 
1028
1115
  if (isCoworker) {
1029
- // 협업자: yy_Coworker_{폴더명}/ 안에 하위 폴더 체크
1030
- const coworkerFolderName = `yy_Coworker_${coworkerFolder}`;
1116
+ // 협업자: zz_Coworker_{폴더명}/ 안에 하위 폴더 체크
1117
+ const coworkerFolderName = `zz_Coworker_${coworkerFolder}`;
1118
+ const coworkerSubFolders = [`${coworkerFolder}_TalkTodoPlan`, '_Private'];
1031
1119
  const coworkerBasePath = path.join(localPath, coworkerFolderName);
1032
1120
 
1033
1121
  // 협업자 루트 폴더
@@ -1176,10 +1264,9 @@ export async function handlePullInternal(args) {
1176
1264
  const serverMeta = serverPathToMeta[folder.path];
1177
1265
  let fullPath;
1178
1266
  if (folder.path.startsWith('xx_') ||
1179
- folder.path.startsWith('yy_All_Docu/') ||
1267
+ folder.path.startsWith('xy_TalkTodoPlan') ||
1180
1268
  folder.path.startsWith('yy_All_Docu') ||
1181
- folder.path.startsWith('yy_Coworker_') ||
1182
- folder.path.startsWith('zz_ai_')) {
1269
+ folder.path.startsWith('zz_Coworker_')) {
1183
1270
  fullPath = path.join(localPath, folder.path);
1184
1271
  } else {
1185
1272
  fullPath = path.join(mainFolderPath, folder.path);
@@ -1209,10 +1296,9 @@ export async function handlePullInternal(args) {
1209
1296
  for (const file of files_only) {
1210
1297
  let fullPath;
1211
1298
  if (file.path.startsWith('xx_') ||
1212
- file.path.startsWith('yy_All_Docu/') ||
1299
+ file.path.startsWith('xy_TalkTodoPlan') ||
1213
1300
  file.path.startsWith('yy_All_Docu') ||
1214
- file.path.startsWith('yy_Coworker_') ||
1215
- file.path.startsWith('zz_ai_')) {
1301
+ file.path.startsWith('zz_Coworker_')) {
1216
1302
  fullPath = path.join(localPath, file.path);
1217
1303
  } else {
1218
1304
  fullPath = path.join(mainFolderPath, file.path);
@@ -1253,9 +1339,51 @@ export async function handlePullInternal(args) {
1253
1339
  } catch (e) {
1254
1340
  // 해시 계산 실패 시 다운로드 대상
1255
1341
  }
1342
+ } else {
1343
+ // ========================================
1344
+ // 로컬에 파일이 없는 경우 (v2 좀비 방지 강화)
1345
+ // 1. files에 있음 = 로컬에서 삭제한 파일 → markAsDeleted 후 스킵
1346
+ // 2. deletedFiles에 있음 = 이미 삭제 표시됨 → 서버 변경 확인 후 처리
1347
+ // 3. 둘 다 없음 = 서버에서 새로 생성된 파일 → 다운로드
1348
+ // ========================================
1349
+
1350
+ // Case 1: files에 아직 있음 (첫 Pull 시점)
1351
+ if (localIndex.files && localIndex.files[file.path]) {
1352
+ console.error(`[DocuKing] 로컬 삭제 감지: ${file.path} → deletedFiles에 기록`);
1353
+ // deletedFiles에 기록
1354
+ const entry = localIndex.files[file.path];
1355
+ markAsDeleted(localIndex, file.path, entry.hash);
1356
+ results.push({ type: 'skip', path: file.path, reason: 'locally-deleted' });
1357
+ skipped++;
1358
+ current++;
1359
+ continue;
1360
+ }
1361
+
1362
+ // Case 2: deletedFiles에 있음 (이전에 삭제 표시됨)
1363
+ const deleted = wasDeletedLocally(localIndex, file.path);
1364
+ if (deleted.isDeleted) {
1365
+ // 서버가 수정되었는지 확인
1366
+ if (serverHash === deleted.entry.lastHash) {
1367
+ // 서버 변경 없음 → 스킵 (좀비 방지)
1368
+ console.error(`[DocuKing] 좀비 방지: ${file.path} (로컬 삭제 유지)`);
1369
+ results.push({ type: 'skip', path: file.path, reason: 'zombie-prevention' });
1370
+ } else {
1371
+ // 서버가 수정됨 → 충돌! (로컬 삭제 vs 서버 수정)
1372
+ console.error(`[DocuKing] ⚠️ 충돌: ${file.path} (로컬 삭제 vs 서버 수정) → 서버 버전 다운로드`);
1373
+ // 충돌 시 서버 버전 다운로드 (사용자가 결정할 수 있도록)
1374
+ filesToDownload.push({ ...file, fullPath, conflict: true });
1375
+ current++;
1376
+ continue;
1377
+ }
1378
+ skipped++;
1379
+ current++;
1380
+ continue;
1381
+ }
1382
+
1383
+ // Case 3: 둘 다 없음 = 새 파일
1256
1384
  }
1257
1385
 
1258
- // 로컬에 파일이 없는 경우만 다운로드
1386
+ // 인덱스에 없고 로컬에 없음 = 서버에서 새로 생성된 파일 → 다운로드
1259
1387
  filesToDownload.push({ ...file, fullPath });
1260
1388
  }
1261
1389
 
@@ -1403,12 +1531,17 @@ export async function handlePullInternal(args) {
1403
1531
  }
1404
1532
 
1405
1533
  // ========================================
1406
- // 로컬 인덱스 업데이트 및 저장
1534
+ // 로컬 인덱스 업데이트 및 저장 (v2)
1407
1535
  // ========================================
1536
+ // Pull 중 발견된 삭제 파일들도 인덱스에 기록됨 (markAsDeleted 호출됨)
1537
+ // 따라서 syncedFiles가 0이어도 인덱스가 변경되었을 수 있음
1538
+ if (saveIndex(localPath, localIndex)) {
1539
+ console.error(`[DocuKing] 로컬 인덱스 업데이트 (lastPull 갱신)`);
1540
+ }
1408
1541
  if (syncedFiles.length > 0) {
1409
- updateIndexAfterSync(localIndex, syncedFiles, []);
1542
+ updateIndexAfterSync(localIndex, syncedFiles, [], { syncType: 'pull' });
1410
1543
  if (saveIndex(localPath, localIndex)) {
1411
- console.error(`[DocuKing] 로컬 인덱스 업데이트: ${syncedFiles.length}개 파일`);
1544
+ console.error(`[DocuKing] 로컬 인덱스: ${syncedFiles.length}개 파일 동기화됨`);
1412
1545
  }
1413
1546
  }
1414
1547
 
@@ -1490,9 +1623,9 @@ export async function handlePullInternal(args) {
1490
1623
  // 서버 경로에 따라 로컬 경로 결정
1491
1624
  let fullPath;
1492
1625
  if (deletedFile.path.startsWith('xx_') ||
1493
- deletedFile.path.startsWith('yy_All_Docu/') ||
1494
- deletedFile.path.startsWith('yy_Coworker_') ||
1495
- deletedFile.path.startsWith('zz_ai_')) {
1626
+ deletedFile.path.startsWith('xy_TalkTodoPlan') ||
1627
+ deletedFile.path.startsWith('yy_All_Docu') ||
1628
+ deletedFile.path.startsWith('zz_Coworker_')) {
1496
1629
  fullPath = path.join(localPath, deletedFile.path);
1497
1630
  } else {
1498
1631
  fullPath = path.join(mainFolderPath, deletedFile.path);
@@ -1516,6 +1649,10 @@ export async function handlePullInternal(args) {
1516
1649
  deletedLocallyPaths.push(deletedFile.path);
1517
1650
  console.error(`[DocuKing] 로컬 삭제 (서버에서 삭제됨): ${deletedFile.path}`);
1518
1651
 
1652
+ // 인덱스에서도 제거 (v2)
1653
+ removeIndexEntry(localIndex, deletedFile.path);
1654
+ clearDeletedEntry(localIndex, deletedFile.path);
1655
+
1519
1656
  // 빈 폴더 정리
1520
1657
  const parentDir = path.dirname(fullPath);
1521
1658
  try {
@@ -1529,6 +1666,8 @@ export async function handlePullInternal(args) {
1529
1666
  }
1530
1667
  } else {
1531
1668
  console.error(`[DocuKing] 로컬 유지 (로컬 수정이 최신): ${deletedFile.path}`);
1669
+ // 다음 Push에서 부활할 것이므로 deletedFiles에서 제거
1670
+ clearDeletedEntry(localIndex, deletedFile.path);
1532
1671
  }
1533
1672
  } catch (e) {
1534
1673
  console.error(`[DocuKing] 삭제 동기화 실패: ${deletedFile.path} - ${e.message}`);
@@ -1780,7 +1919,7 @@ export async function handleStatus(args) {
1780
1919
 
1781
1920
  // Co-worker 권한은 API Key 형식에서 판단
1782
1921
  const { isCoworker, coworkerFolder } = parseCoworkerFromApiKey(apiKey);
1783
- const coworkerFolderName = isCoworker ? `yy_Coworker_${coworkerFolder}` : null;
1922
+ const coworkerFolderName = isCoworker ? `zz_Coworker_${coworkerFolder}` : null;
1784
1923
 
1785
1924
  // 권한 정보 구성
1786
1925
  let permissionInfo = '';
@@ -1822,21 +1961,24 @@ export async function handleStatus(args) {
1822
1961
  const mainFolderPath = path.join(localPath, 'yy_All_Docu');
1823
1962
 
1824
1963
  if (isCoworker) {
1825
- // 협업자: yy_Coworker_{폴더명}/ 폴더에서 파일 수집
1964
+ // 협업자: zz_Coworker_{폴더명}/ 폴더에서 파일 수집
1826
1965
  const coworkerPath = path.join(localPath, coworkerFolderName);
1827
1966
  if (fs.existsSync(coworkerPath)) {
1828
1967
  collectFilesSimple(coworkerPath, '', localFiles);
1829
1968
  }
1830
1969
  pushableFiles = localFiles; // 협업자는 자기 폴더의 모든 파일 Push 가능
1831
1970
  } else {
1832
- // 오너: xx_*/ + yy_All_Docu/ + zz_ai_*/ 폴더에서 파일 수집
1971
+ // 오너: xx_*/ + xy_*/ + yy_All_Docu/ 폴더에서 파일 수집
1833
1972
  if (fs.existsSync(mainFolderPath)) {
1834
1973
  collectFilesSimple(mainFolderPath, '', localFiles);
1835
1974
  }
1836
- // xx_*, zz_ai_* 폴더들도 수집
1975
+ // 특수폴더들도 수집: xx_*, xy_TalkTodoPlan
1837
1976
  const rootEntries = fs.readdirSync(localPath, { withFileTypes: true });
1838
1977
  for (const entry of rootEntries) {
1839
- if (entry.isDirectory() && (entry.name.startsWith('xx_') || entry.name.startsWith('zz_ai_'))) {
1978
+ if (entry.isDirectory() && (
1979
+ entry.name.startsWith('xx_') ||
1980
+ entry.name === 'xy_TalkTodoPlan'
1981
+ )) {
1840
1982
  const folderPath = path.join(localPath, entry.name);
1841
1983
  collectFilesSimple(folderPath, '', localFiles);
1842
1984
  }
@@ -1914,7 +2056,7 @@ export async function handleDelete(args) {
1914
2056
 
1915
2057
  // 협업자는 자기 폴더만 삭제 가능
1916
2058
  if (isCoworker) {
1917
- const coworkerPrefix = `yy_Coworker_${coworkerFolder}/`;
2059
+ const coworkerPrefix = `zz_Coworker_${coworkerFolder}/`;
1918
2060
  const invalidPaths = paths.filter(p => !p.startsWith(coworkerPrefix) && !p.startsWith('xx_Urgent/'));
1919
2061
  if (invalidPaths.length > 0) {
1920
2062
  return {
@@ -1929,7 +2071,7 @@ export async function handleDelete(args) {
1929
2071
 
1930
2072
  // 오너도 협업자 폴더는 삭제 금지
1931
2073
  if (!isCoworker) {
1932
- const coworkerPaths = paths.filter(p => p.startsWith('yy_Coworker_'));
2074
+ const coworkerPaths = paths.filter(p => p.startsWith('zz_Coworker_'));
1933
2075
  if (coworkerPaths.length > 0 && !force) {
1934
2076
  return {
1935
2077
  content: [{
@@ -2005,9 +2147,9 @@ export async function handleDelete(args) {
2005
2147
  try {
2006
2148
  let localFilePath;
2007
2149
  if (serverPath.startsWith('xx_') ||
2008
- serverPath.startsWith('yy_All_Docu/') ||
2009
- serverPath.startsWith('yy_Coworker_') ||
2010
- serverPath.startsWith('zz_ai_')) {
2150
+ serverPath.startsWith('xy_TalkTodoPlan') ||
2151
+ serverPath.startsWith('yy_All_Docu') ||
2152
+ serverPath.startsWith('zz_Coworker_')) {
2011
2153
  localFilePath = path.join(localPath, serverPath);
2012
2154
  } else {
2013
2155
  localFilePath = path.join(localPath, 'yy_All_Docu', serverPath);