deel-local-cli 1.20.13 → 2.0.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.
Files changed (155) hide show
  1. package/README.ko.md +96 -78
  2. package/README.md +119 -81
  3. package/bin/deel.js +398 -32
  4. package/package.json +2 -1
  5. package/src/acp/jsonrpc.js +87 -7
  6. package/src/acp/map.js +23 -3
  7. package/src/acp/serve.js +333 -25
  8. package/src/agent/agents.js +110 -12
  9. package/src/agent/askcheck.js +50 -6
  10. package/src/agent/asks.js +59 -5
  11. package/src/agent/budget.js +9 -2
  12. package/src/agent/card.js +8 -2
  13. package/src/agent/commit.js +168 -16
  14. package/src/agent/compact.js +93 -21
  15. package/src/agent/{/354/213/240/353/242/260/353/217/204.js → confidence.js} +1 -1
  16. package/src/agent/effort.js +85 -15
  17. package/src/agent/evidence.js +63 -7
  18. package/src/agent/evolve.js +132 -24
  19. package/src/agent/grade.js +12 -3
  20. package/src/agent/loop.js +408 -81
  21. package/src/agent/memory.js +176 -29
  22. package/src/agent/mention.js +22 -5
  23. package/src/agent/models.js +31 -7
  24. package/src/agent/modes.js +25 -10
  25. package/src/agent/outschema.js +381 -56
  26. package/src/agent/pins.js +33 -2
  27. package/src/agent/project.js +73 -8
  28. package/src/agent/recall.js +40 -9
  29. package/src/agent/{/354/204/261/355/225/234/352/270/260/353/241/235.js → recordshape.js} +19 -8
  30. package/src/agent/review.js +143 -13
  31. package/src/agent/route.js +253 -26
  32. package/src/agent/salvage.js +2 -1
  33. package/src/agent/session.js +457 -31
  34. package/src/agent/store.js +232 -22
  35. package/src/agent/threads.js +52 -3
  36. package/src/backend/adapter.js +508 -85
  37. package/src/backend/azure.js +12 -1
  38. package/src/backend/cachemark.js +31 -5
  39. package/src/backend/clientcert.js +23 -1
  40. package/src/backend/ctxsize.js +56 -9
  41. package/src/backend/detect.js +252 -19
  42. package/src/backend/http.js +383 -42
  43. package/src/backend/learn.js +101 -11
  44. package/src/backend/mcp.js +333 -24
  45. package/src/backend/price.js +61 -13
  46. package/src/backend/probe.js +188 -36
  47. package/src/backend/proxy.js +108 -14
  48. package/src/backend/quota.js +226 -30
  49. package/src/backend/retry.js +46 -3
  50. package/src/backend/scan.js +47 -10
  51. package/src/backend/scanui.js +57 -7
  52. package/src/backend/toolfit.js +204 -22
  53. package/src/backend/vision.js +14 -1
  54. package/src/backend/wire.js +58 -10
  55. package/src/cmdnames.js +16 -0
  56. package/src/commands/common.js +70 -0
  57. package/src/commands/extend.js +300 -0
  58. package/src/commands/model.js +852 -0
  59. package/src/commands/view.js +328 -0
  60. package/src/commands/work.js +823 -0
  61. package/src/commands.js +193 -2082
  62. package/src/completion.js +45 -3
  63. package/src/config.js +452 -39
  64. package/src/configexplain.js +125 -14
  65. package/src/doctor.js +164 -26
  66. package/src/i18n/en.js +22 -0
  67. package/src/i18n/index.js +31 -1
  68. package/src/i18n/ja.js +25 -1
  69. package/src/i18n/ko.js +25 -2
  70. package/src/i18n/zh.js +25 -1
  71. package/src/lsp/client.js +111 -9
  72. package/src/lsp/servers.js +86 -22
  73. package/src/oneshot.js +325 -65
  74. package/src/pack/sbom.js +28 -5
  75. package/src/pack/selfpack.js +54 -5
  76. package/src/pack/sheet.en.js +24 -3
  77. package/src/pack/tar.js +45 -8
  78. package/src/pack/zip.js +103 -8
  79. package/src/plugins/manage.js +165 -14
  80. package/src/preview/serve.js +239 -23
  81. package/src/providers/bedrock.js +30 -5
  82. package/src/providers/gemini.js +8 -0
  83. package/src/providers/index.js +17 -1
  84. package/src/repl.js +254 -61
  85. package/src/report.js +7 -2
  86. package/src/reset.js +151 -25
  87. package/src/safety/audit.js +108 -8
  88. package/src/safety/authcmd.js +75 -5
  89. package/src/safety/guard.js +507 -15
  90. package/src/safety/hooks.js +172 -20
  91. package/src/safety/keystore.js +178 -18
  92. package/src/safety/network.js +209 -6
  93. package/src/safety/policy.js +377 -36
  94. package/src/safety/runmode.js +9 -2
  95. package/src/safety/secrets.js +243 -17
  96. package/src/safety/shellenv.js +64 -3
  97. package/src/safety/trust.js +298 -32
  98. package/src/safety/undo.js +409 -17
  99. package/src/setup.js +175 -17
  100. package/src/skills/discover.js +122 -19
  101. package/src/stats.js +57 -16
  102. package/src/tools/{/355/231/225/354/235/270/353/262/225.js → checkmethods.js} +26 -1
  103. package/src/tools/clipboard.js +53 -20
  104. package/src/tools/convert.js +118 -37
  105. package/src/tools/desc.en.js +48 -5
  106. package/src/tools/doc2md.js +11 -1
  107. package/src/tools/docs.js +94 -18
  108. package/src/tools/edit-match.js +212 -13
  109. package/src/tools/encoding.js +525 -28
  110. package/src/tools/excel-com.js +63 -15
  111. package/src/tools/excel.js +12 -7
  112. package/src/tools/fastgrep.js +444 -36
  113. package/src/tools/fig.js +21 -4
  114. package/src/tools/fsutil.js +235 -21
  115. package/src/tools/hwpxwrite.js +80 -19
  116. package/src/tools/ignore.js +55 -7
  117. package/src/tools/index.js +1029 -145
  118. package/src/tools/jobs.js +325 -50
  119. package/src/tools/kiwi.js +50 -5
  120. package/src/tools/lsp.js +164 -32
  121. package/src/tools/outline.js +52 -12
  122. package/src/tools/pdf.js +366 -56
  123. package/src/tools/shell.js +12 -4
  124. package/src/tools/spawn.js +203 -18
  125. package/src/tools/todo.js +73 -4
  126. package/src/tools/verify.js +312 -43
  127. package/src/tools/webfetch.js +184 -25
  128. package/src/tools/xlsx.js +232 -31
  129. package/src/ui/ansi.js +209 -15
  130. package/src/ui/banner.js +2 -1
  131. package/src/ui/complete.js +46 -4
  132. package/src/ui/diff.js +4 -2
  133. package/src/ui/export.js +91 -17
  134. package/src/ui/inputbox.js +58 -18
  135. package/src/ui/intro.js +9 -2
  136. package/src/ui/level.js +32 -7
  137. package/src/ui/md.js +101 -15
  138. package/src/ui/motion.js +5 -2
  139. package/src/ui/notify.js +7 -1
  140. package/src/ui/office.js +37 -5
  141. package/src/ui/pastechip.js +3 -2
  142. package/src/ui/pick.js +85 -12
  143. package/src/ui/prompt.js +176 -13
  144. package/src/ui/screen.js +2 -1
  145. package/src/ui/spinner.js +6 -1
  146. package/src/ui/status.js +80 -12
  147. package/src/ui/wrap.js +23 -37
  148. /package/src/agent/{/353/213/250/352/263/204.js" → phase.js} +0 -0
  149. /package/src/skills/builtin/{/352/271/212/354/235/264/354/236/210/352/262/214-/353/247/214/353/223/244/352/270/260/SKILL.md" → build-deep/SKILL.md} +0 -0
  150. /package/src/skills/builtin/{/354/260/250/352/267/274/354/260/250/352/267/274-/353/224/224/353/262/204/352/271/205/SKILL.md" → debug-step-by-step/SKILL.md} +0 -0
  151. /package/src/skills/builtin/{/353/201/235/352/271/214/354/247/200-/355/225/230/352/270/260/SKILL.md" → finish-all/SKILL.md} +0 -0
  152. /package/src/skills/builtin/{/354/212/244/354/212/244/353/241/234-/352/262/200/355/206/240/SKILL.md" → self-review/SKILL.md} +0 -0
  153. /package/src/skills/builtin/{/354/275/224/353/223/234-/354/244/204/354/235/264/352/270/260/SKILL.md" → simplify-code/SKILL.md} +0 -0
  154. /package/src/skills/builtin/{/354/260/224/353/237/254/353/263/264/352/270/260/SKILL.md" → spike/SKILL.md} +0 -0
  155. /package/src/skills/builtin/{/352/262/200/354/202/254-/353/250/274/354/240/200/SKILL.md" → test-first/SKILL.md} +0 -0
@@ -1,25 +1,147 @@
1
1
  // 되돌리기. 승인 프롬프트를 안 쓰는 대신 이게 안전망이다.
2
2
  // 파일을 고치기 전에 항상 이전 내용을 떠 놓고, /undo 로 턴 단위로 되돌린다.
3
- import { join, dirname } from 'node:path';
4
- import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, appendFileSync, statSync, chmodSync } from 'node:fs';
3
+ import { join, dirname, basename, resolve, relative, isAbsolute, sep } from 'node:path';
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, appendFileSync, statSync, chmodSync, renameSync, realpathSync, openSync, readSync, closeSync } from 'node:fs';
5
5
  import { looksBinary } from '../tools/encoding.js';
6
+ import { 살림폴더만들기 } from './audit.js';
6
7
  import { 말 } from '../i18n/index.js';
7
8
 
8
9
  // 되돌리기 이력은 파일 내용을 통째로 담는다. 이만큼 커지면 오래된 턴을 버린다.
9
10
  const MAX_BYTES = 32 * 1024 * 1024;
10
11
  const KEEP_TURNS = 50;
11
12
 
13
+ /*
14
+ * ── 이력이 가리키는 곳을 그대로 믿지 않는다 ─────────────────────────────
15
+ *
16
+ * edits.jsonl 의 한 줄은 「이 경로의 원래 내용」 이고, /undo 는 그 경로에 쓰고 지운다.
17
+ * 그런데 그 파일은 `.deel/history` 에 있다 — 저장소에 딸려 올 수 있고(누가 올렸든
18
+ * 일부러 넣었든), 폴더째 복사될 수도 있다. 여태 적힌 절대 경로를 그대로 써서:
19
+ *
20
+ * · 받은 저장소의 이력 한 줄로 **작업 폴더 밖** 파일을 덮고, 지우고, 폴더를 만들었다
21
+ * · 복사한 폴더에서 /undo 하면 **원본 폴더**를 되돌렸다 (복사본은 그대로)
22
+ *
23
+ * 둘 다 「되돌렸습니다」 가 찍혔다. 안전망이 울타리 밖으로 손을 뻗는 꼴이다.
24
+ *
25
+ * 그래서 새 기록에는 작업 폴더 기준 **상대 경로(rel)** 를 같이 적고, 읽을 때는 그것을
26
+ * 지금 폴더에 붙여 쓴다. 옛 기록(rel 없음)의 절대 경로는 지금 폴더 **안**일 때만 받는다.
27
+ * 어느 쪽이든 밖으로 나가면 건드리지 않고 그렇다고 적는다. 글자로는 안인데 고리
28
+ * (정션·심볼릭 링크)를 따라가면 밖인 자리도 건드리기 직전에 따로 본다 — 저장소는
29
+ * 링크를 실어 나를 수 있다.
30
+ *
31
+ * `path`(절대 경로)는 계속 적는다. 이 칸만 읽는 옛 deel 이 같은 폴더에서 이력을 열어도
32
+ * 여태처럼 되돌리게 하려는 것이다. 이 판은 rel 이 있으면 rel 만 믿는다.
33
+ *
34
+ * 믿는 폴더에서만 이력을 읽게 막는 길은 안 골랐다. 폴더는 기본이 「안 믿음」 이라 그러면
35
+ * 거의 모든 폴더에서 /undo 가 꺼진다 — 안전망을 끄는 것으로 안전망을 지키는 셈이다.
36
+ * 밖으로 못 나가게 막으면, 딸려 온 이력이 할 수 있는 일은 저장소가 제 파일을 제 내용으로
37
+ * 되돌리는 것뿐이고 그건 저장소가 이미 할 수 있는 일이다.
38
+ */
39
+
40
+ /** abs 가 뿌리 안(뿌리 자신은 빼고)인가. 글자로만 본다. */
41
+ function 안인가(뿌리, abs) {
42
+ const rel = relative(뿌리, abs);
43
+ return !!rel && rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel);
44
+ }
45
+
46
+ /*
47
+ * 링크를 따라간 진짜 자리. 아직 없는 파일은 있는 자리까지 올라가서 푼다.
48
+ * safety/guard.js 의 진짜자리와 같은 방법이다(8.3 단축명 때문에 native 를 먼저 부른다).
49
+ * 그쪽을 불러오지 않는 까닭은 guard.js 가 설정까지 끌고 오기 때문이다 — 되돌리기는
50
+ * 설정이 깨져도 돌아야 하는 자리다.
51
+ */
52
+ function 진짜자리(p) {
53
+ const 남은 = [];
54
+ let cur = p;
55
+ for (let i = 0; i < 64; i++) {
56
+ try {
57
+ let r;
58
+ try { r = realpathSync.native(cur); } catch { r = realpathSync(cur); }
59
+ return 남은.length ? resolve(r, ...남은) : r;
60
+ } catch { /* 아직 없는 자리 — 한 칸 위로 */ }
61
+ const 위 = dirname(cur);
62
+ if (위 === cur) return p;
63
+ 남은.unshift(basename(cur));
64
+ cur = 위;
65
+ }
66
+ return p;
67
+ }
68
+
69
+ /** 파일에 적는 모양. 읽을 때 붙인 표(바깥)는 안 적는다 — 적힌 원래 줄을 그대로 둔다. */
70
+ function 적을줄(r) {
71
+ const { 바깥: _바깥, ...나머지 } = r;
72
+ return JSON.stringify(나머지);
73
+ }
74
+
12
75
  export class History {
13
76
  constructor(root) {
14
- this.dir = join(root, '.deel', 'history');
77
+ /** 이 이력이 되돌려도 되는 울타리. 이 밖은 적혀 있어도 안 건드린다. */
78
+ this.root = resolve(String(root));
79
+ this.dir = join(this.root, '.deel', 'history');
15
80
  // 폴더부터 본인 것으로. 파일만 잠그고 폴더가 0755 면 이름은 다 보인다.
16
- mkdirSync(this.dir, { recursive: true, mode: 0o700 });
81
+ // 못 만들면 까닭과 길을 사람 말로 던진다 (audit.js 의 살림폴더만들기 · 6회차 C3).
82
+ 살림폴더만들기(this.dir, { mode: 0o700 });
17
83
  this.file = join(this.dir, 'edits.jsonl');
18
84
  this.turn = 0;
19
85
  /** 잠근 결과. 밖에서 볼 수 있어야 「잠갔다」 는 말이 뜻을 갖는다. */
20
86
  this.잠금 = null;
21
87
  }
22
88
 
89
+ /** 새 기록에 적을 상대 경로. 뿌리 밖이면 null — 그런 기록은 되돌릴 때 안 받는다. */
90
+ #상대(abs) {
91
+ const a = resolve(String(abs));
92
+ return 안인가(this.root, a) ? relative(this.root, a).split(sep).join('/') : null;
93
+ }
94
+
95
+ /*
96
+ * 적힌 칸을 지금 폴더의 절대 경로로. 밖을 가리키면 null.
97
+ * rel 이 있으면 rel 만 믿는다 — 절대 경로 칸이 안이라고 적혀 있어도 rel 이 밖이면 밖이다.
98
+ */
99
+ #자리풀기(적힌절대, 적힌상대) {
100
+ let abs = null;
101
+ if (typeof 적힌상대 === 'string') abs = isAbsolute(적힌상대) ? null : resolve(this.root, 적힌상대);
102
+ else if (typeof 적힌절대 === 'string' && isAbsolute(적힌절대)) abs = resolve(적힌절대);
103
+ return abs && 안인가(this.root, abs) ? abs : null;
104
+ }
105
+
106
+ /** 링크를 따라가도 뿌리 안인가. 건드리기 직전에만 본다 — 줄마다 디스크를 묻지 않게. */
107
+ #링크도안인가(abs) {
108
+ const 뿌리 = 진짜자리(this.root);
109
+ return 안인가(뿌리, 진짜자리(abs));
110
+ }
111
+
112
+ /*
113
+ * ── 반쪽 줄 뒤에 이어 적지 않는다 ─────────────────────────────────────
114
+ *
115
+ * 적다가 죽으면 마지막 줄이 개행 없이 남는다. 다음 판의 첫 기록이 그 뒤에 붙어 한 줄이
116
+ * 되고, 그 줄은 JSON 이 아니라 all() 이 버린다. 그러면 **이번 판의 턴이 이력에 없어서**
117
+ * /undo 한 번이 앞 판의 턴을 되돌리고 「되돌렸습니다」 라고 했다 — 시키지 않은 되돌리기다.
118
+ * agent/store.js · safety/audit.js 와 같은 방법이다. 이 이력으로 처음 적을 때 한 번 파일 끝을
119
+ * 보고, 개행이 아니면 개행부터 붙인다. 통째로 다시 쓰는 자리(prune·undo·버리기)는 언제나
120
+ * 개행으로 끝내므로 한 번 본 것이 계속 맞다.
121
+ */
122
+ #줄끝봤나 = false;
123
+ #줄로끝나나() {
124
+ let fd = null;
125
+ try {
126
+ const 크기 = statSync(this.file).size;
127
+ if (!크기) return true;
128
+ fd = openSync(this.file, 'r');
129
+ const 한바이트 = Buffer.alloc(1);
130
+ readSync(fd, 한바이트, 0, 1, 크기 - 1);
131
+ return 한바이트[0] === 0x0a;
132
+ } catch { return true; } // 없거나 못 읽으면 붙일 반쪽도 없다
133
+ finally { if (fd != null) { try { closeSync(fd); } catch { /* 닫다 터져도 적기는 한다 */ } } }
134
+ }
135
+
136
+ #덧붙이기(rec) {
137
+ let 줄 = 적을줄(rec) + '\n';
138
+ if (!this.#줄끝봤나) {
139
+ if (!this.#줄로끝나나()) 줄 = '\n' + 줄;
140
+ this.#줄끝봤나 = true;
141
+ }
142
+ appendFileSync(this.file, 줄, 'utf8');
143
+ }
144
+
23
145
  /*
24
146
  * ── 이 파일은 남의 파일 **원문**을 담는다 ───────────────────────────────
25
147
  *
@@ -44,6 +166,9 @@ export class History {
44
166
  #잠그기() {
45
167
  if (this.#잠갔나) return;
46
168
  this.#잠갔나 = true;
169
+ // 윈도우에서 chmod 는 아무 일도 안 하고 **성공한다.** 그 성공을 적으면 위 머리말이 금지한
170
+ // 「잠근 척」 이다 — agent/store.js 와 같이 안 걸었다고 적는다.
171
+ if (process.platform === 'win32') { this.잠금 = { 못함: 'windows' }; return; }
47
172
  try { chmodSync(this.file, 0o600); this.잠금 = { 모드: 0o600 }; }
48
173
  catch (err) { this.잠금 = { 못함: err?.code ?? String(err) }; }
49
174
  }
@@ -66,6 +191,7 @@ export class History {
66
191
  const 지금 = Date.now();
67
192
  this.turn = 지금 > this.turn ? 지금 : this.turn + 1;
68
193
  this.#이번턴 = new Map();
194
+ this.#낮춘이름 = new Map();
69
195
  return this.turn;
70
196
  }
71
197
 
@@ -96,16 +222,168 @@ export class History {
96
222
  * 파일이 여덟 벌 쌓이고, 이력이 32MB 를 넘으면 오래된 턴부터 버려진다 —
97
223
  * 즉 **정말 되돌려야 할 옛 기록이 밀려난다.** 안전망이 제 무게로 무너진다.
98
224
  */
99
- const 이미 = this.#이번턴.get(absPath);
225
+ const 이미 = this.#이번턴.get(absPath) ?? this.#같은파일기록(absPath);
100
226
  if (이미) return 이미;
101
227
 
102
- const 뜬것 = existsSync(absPath) ? safeRead(absPath) : { before: null };
228
+ const 없던 = !existsSync(absPath);
229
+ const 뜬것 = 없던 ? { before: null } : safeRead(absPath);
103
230
  const rec = { turn: this.turn, at: new Date().toISOString(), path: absPath, before: 뜬것.before, label };
231
+ /*
232
+ * 「원래 없던 자리」 라는 **표**를 따로 단다 (6회차 Gemini 되돌리기6).
233
+ *
234
+ * before:null 만으로는 옛 이력(skipped 이전)의 「못 뜬 파일」 과 안 갈려서, undo() 는 지금 바이너리면
235
+ * 안 지운다. 그 갈래가 새 기록에도 걸려 `cp img.png copy.png` · hwpx 만들기처럼 이번 턴에 **새로 만든**
236
+ * 그림·문서가 /undo 뒤에도 남았다. 이 표가 있으면 정말 없던 자리라 바이너리여도 지운다.
237
+ */
238
+ if (없던) rec.없던 = true;
239
+ // 작업 폴더 기준 자리. 폴더를 옮기거나 복사해도 이 이력은 **그 폴더**를 되돌린다 (위 머리말).
240
+ const 상대 = this.#상대(absPath);
241
+ if (상대 !== null) rec.rel = 상대;
104
242
  if (뜬것.enc) rec.enc = 뜬것.enc;
105
243
  if (뜬것.skipped) rec.skipped = 뜬것.skipped;
106
- appendFileSync(this.file, JSON.stringify(rec) + '\n', 'utf8');
244
+ /*
245
+ * 못 뜬 파일은 **크기**를 적어 둔다 (6회차 Gemini 도구6j).
246
+ *
247
+ * 옮기기(Move · 셸 mv)는 떠난 자리를 뜨고 닿은 자리를 없던 자리로 뜬다. 그림은 떠난 자리를 못 떠서 되돌릴 수
248
+ * 없는데, undo() 가 없던 자리를 바이너리여도 지우면 그림이 **한 벌도 안 남았다.** 크기가 같은 새 바이너리는
249
+ * 옮겨 온 마지막 한 벌일 수 있어 undo() 가 안 지운다(undo 의 사라진못뜬크기).
250
+ */
251
+ if (뜬것.skipped) { try { rec.크기 = statSync(absPath).size; } catch { /* 못 재면 크기 모르는 기록 — undo() 가 옮겨 왔을 수 있다고 본다 */ } }
252
+ this.#덧붙이기(rec);
253
+ this.#잠그기();
254
+ this.#이번턴.set(absPath, rec);
255
+ const 낮춘 = absPath.toLowerCase();
256
+ this.#낮춘이름.set(낮춘, [...(this.#낮춘이름.get(낮춘) ?? []), absPath]);
257
+ this.#maybePrune();
258
+ return rec;
259
+ }
260
+
261
+ /*
262
+ * ── 대소문자만 다른 이름으로 **같은 파일**을 또 뜨지 않는다 ────────────
263
+ *
264
+ * 윈도우·맥은 `a.txt` 와 `A.txt` 를 한 파일로 친다. 위 #이번턴 은 글자로
265
+ * 견주어서, 한 턴에 `Write a.txt` 뒤 `Edit A.txt` 를 하면 기록이 둘 생겼다
266
+ * — 둘째는 첫 쓰기가 끝난 **뒤의** 내용이다. /undo 는 두 기록을 차례로
267
+ * 써서, 턴 처음이 아니라 **중간 상태**로 되돌려 놓고 「되돌렸습니다」 를 찍었다.
268
+ *
269
+ * 글자가 대소문자만 다를 때 디스크의 같은 물건인지(dev·ino)를 본다.
270
+ * 가리는 판(리눅스)에서는 서로 다른 파일이라 번호가 달라 안 걸린다.
271
+ */
272
+ #낮춘이름 = new Map();
273
+ #같은파일기록(absPath) {
274
+ const 뭉치 = this.#낮춘이름.get(absPath.toLowerCase());
275
+ if (!뭉치) return null;
276
+ for (const p of 뭉치) {
277
+ if (p !== absPath && 같은물건(p, absPath)) return this.#이번턴.get(p) ?? null;
278
+ }
279
+ return null;
280
+ }
281
+
282
+ /**
283
+ * 이번 턴에 이 파일을 이미 떴나.
284
+ *
285
+ * 쓰기가 깨졌을 때 **이번에 뜬 기록만** 거두려고, 부르는 쪽이 뜨기 전에 본다.
286
+ * 같은 턴에 앞서 성공한 고치기의 기록은 턴 처음 모습이라 거두면 안 된다.
287
+ */
288
+ 떴나(absPath) {
289
+ return this.#이번턴.has(absPath) || !!this.#같은파일기록(absPath);
290
+ }
291
+
292
+ /*
293
+ * ── **명령 뒤에** 새로 생긴 파일을 「없던 자리」 로 적는다 (6회차 — 셸 cp·mv 의 새 이름) ──────
294
+ *
295
+ * `cp a b` · `mv a b` · `mv a sub` 의 새 이름은 명령 **뒤에야** 생긴다. 셸 스냅샷은 지금 있는 이름만
296
+ * 떴다 — 없는 이름을 미리 뜨면 `rm *.tmp` 류에 헛기록이 쌓여서다(tools/index.js 바꾸기전스냅샷).
297
+ * 그래서 /undo 뒤에 사본이 남았고, mv 는 원래 자리가 살아난 채 새 이름도 남아 두 벌이 됐다.
298
+ *
299
+ * 없던 자리의 앞 모습은 뜨지 않아도 안다 — 없었다. 그래서 생긴 것을 본 **뒤에** 적는다.
300
+ * 이번 턴에 이미 뜬 자리면 그 기록이 턴 처음 모습이라 안 적는다.
301
+ */
302
+ 없던자리기록(absPath, label) {
303
+ if (this.떴나(absPath)) return null;
304
+ const rec = { turn: this.turn, at: new Date().toISOString(), path: absPath, before: null, 없던: true, label };
305
+ const 상대 = this.#상대(absPath);
306
+ if (상대 !== null) rec.rel = 상대;
307
+ this.#덧붙이기(rec);
107
308
  this.#잠그기();
108
309
  this.#이번턴.set(absPath, rec);
310
+ const 낮춘 = absPath.toLowerCase();
311
+ this.#낮춘이름.set(낮춘, [...(this.#낮춘이름.get(낮춘) ?? []), absPath]);
312
+ this.#maybePrune();
313
+ return rec;
314
+ }
315
+
316
+ /*
317
+ * ── 떠 놓고 **못 쓴** 기록을 거둔다 ───────────────────────────────────
318
+ *
319
+ * Write·Edit·Append·Move 는 정말 쓰기 직전에 뜬다. 그런데 그 쓰기 자체가
320
+ * 깨지는 판이 있다 — 읽기 전용 파일(EPERM·EACCES), 엑셀·한글이 잡고 있는
321
+ * 파일(EBUSY). 파일은 한 글자도 안 바뀌었는데 기록은 남아서, turns() 가
322
+ * 그 턴을 세고 /undo 한 번이 그 헛턴에 먹혔다. 사람이 되돌리려던 **앞 턴**의
323
+ * 진짜 변경은 그대로 남는다. 여태 기록을 지울 길이 여기에 없었다.
324
+ *
325
+ * 파일이 정말 그대로인지는 부르는 쪽이 본다(쓰다 반쯤 깨졌으면 기록이 있어야
326
+ * 되돌린다). 이 함수는 이번 턴의 그 기록 한 줄만 지운다.
327
+ *
328
+ * @param {string} absPath
329
+ * @param {object} [그기록] 지울 기록. 없으면 이번 턴에 그 파일을 뜬 기록.
330
+ * @returns {boolean} 지웠나
331
+ */
332
+ 버리기(absPath, 그기록 = null) {
333
+ const rec = 그기록 ?? this.#이번턴.get(absPath);
334
+ if (!rec || rec.turn !== this.turn) return false;
335
+ // 읽은 기록의 path 는 지금 폴더에 붙여 편 것이다(all). 적을 때 쓴 rel 이 있으면 그걸로 견준다 —
336
+ // 절대 경로 글자는 대소문자·꼴이 조금만 달라도 남남이 된다.
337
+ const 같은자리 = (r) => (rec.rel != null ? r.rel === rec.rel : r.path === rec.path);
338
+ const 그것인가 = (r) => r.turn === rec.turn && 같은자리(r) && r.at === rec.at
339
+ && (r.이름되돌림rel ?? r.이름되돌림 ?? null) === (rec.이름되돌림rel ?? rec.이름되돌림 ?? null);
340
+ let recs;
341
+ try { recs = this.all(); } catch { return false; }
342
+ const 남길것 = recs.filter((r) => !그것인가(r));
343
+ if (남길것.length !== recs.length) {
344
+ try {
345
+ writeFileSync(this.file, 남길것.map(적을줄).join('\n') + (남길것.length ? '\n' : ''), 'utf8');
346
+ this.#잠갔나 = false; this.#잠그기();
347
+ } catch { return false; }
348
+ }
349
+ if (this.#이번턴.get(rec.path) === rec) {
350
+ this.#이번턴.delete(rec.path);
351
+ const 낮춘 = rec.path.toLowerCase();
352
+ const 남은 = (this.#낮춘이름.get(낮춘) ?? []).filter((p) => p !== rec.path);
353
+ if (남은.length) this.#낮춘이름.set(낮춘, 남은); else this.#낮춘이름.delete(낮춘);
354
+ }
355
+ return true;
356
+ }
357
+
358
+ /*
359
+ * ── 대소문자만 바꾸는 이름 바꾸기는 **이름 바꾸기로** 적는다 ──────────────
360
+ *
361
+ * 내용은 안 바뀌니 내용을 뜰 것이 없다. 되돌릴 것은 이름 하나다. 그런데
362
+ * 기록 모양이 「이 경로의 원래 내용」 하나뿐이라, tools/index.js 는 뜨는
363
+ * **순서**로 이름을 되돌리려 했다 — [A.txt: 없던 파일] 을 먼저, [a.txt: 내용]
364
+ * 을 뒤에 남겨 지우고 다시 만들게. 그 수는 세 곳에서 샜다:
365
+ *
366
+ * · 이 턴에 이미 뜬 파일 — 앞 기록이 먼저라 순서를 못 바꾼다 (이름 못 되돌림)
367
+ * · 폴더 — 안의 파일만 떠서 폴더 이름은 안 돌아온다
368
+ * · 그림처럼 내용을 못 뜨는 파일 — 지우지도 만들지도 못해 이름이 남는다
369
+ *
370
+ * 그래서 { path: 새이름, 이름되돌림: 옛이름 } 을 따로 적고, undo() 가 내용을
371
+ * 다 되돌린 **뒤에** 적은 반대 순서로 이름을 되돌린다. 내용 기록과 순서가
372
+ * 얽히지 않으니 위 셋이 다 된다.
373
+ *
374
+ * `before: null` 과 `skipped` 를 같이 적는 것은 **옛 deel 이 읽을 때**를 위해서다.
375
+ * 이 필드를 모르는 판은 skipped 를 보고 건드리지 않는다 — before:null 만
376
+ * 있으면 「없던 파일」 로 읽어 지운다.
377
+ */
378
+ 이름바꿈기록(앞, 뒤, label) {
379
+ const rec = { turn: this.turn, at: new Date().toISOString(), path: 뒤, before: null, skipped: '이름바꿈', 이름되돌림: 앞, label };
380
+ // 두 이름 다 작업 폴더 기준으로도 적는다. 되돌릴 때 둘 중 하나라도 밖이면 이름을 안 바꾼다.
381
+ const 뒤상대 = this.#상대(뒤);
382
+ const 앞상대 = this.#상대(앞);
383
+ if (뒤상대 !== null) rec.rel = 뒤상대;
384
+ if (앞상대 !== null) rec.이름되돌림rel = 앞상대;
385
+ this.#덧붙이기(rec);
386
+ this.#잠그기();
109
387
  this.#maybePrune();
110
388
  return rec;
111
389
  }
@@ -171,12 +449,15 @@ export class History {
171
449
  return 0;
172
450
  }
173
451
  if (turns.length <= keep) return 0;
174
- const 남길턴 = new Set(turns.slice(-keep));
452
+ // keep 0 은 「남길 턴이 없다」 는 말이다. `slice(-0)` 은 `slice(0)` 이라 **전부** 남겨
453
+ // 한 줄도 안 버렸다 — 이력을 비우라고 부른 쪽은 비운 줄 알고 32MB 를 이고 간다.
454
+ // 지금 실호출부는 기본값(KEEP_TURNS)만 쓰지만, 0 을 「전부 남김」 으로 읽는 셈은 이름과 반대다.
455
+ const 남길턴 = new Set(keep > 0 ? turns.slice(-keep) : []);
175
456
  const 남길것 = recs.filter((r) => 남길턴.has(r.turn));
176
457
  const 버린수 = recs.length - 남길것.length;
177
458
  if (!버린수) return 0;
178
459
  try {
179
- writeFileSync(this.file, 남길것.map((r) => JSON.stringify(r)).join('\n') + (남길것.length ? '\n' : ''), 'utf8');
460
+ writeFileSync(this.file, 남길것.map(적을줄).join('\n') + (남길것.length ? '\n' : ''), 'utf8');
180
461
  // 통째로 다시 쓰면 새 파일일 수 있다 — 빗장을 다시 건다 (agent/store.js 도 같다).
181
462
  this.#잠갔나 = false; this.#잠그기();
182
463
  this.줄이기못함 = null;
@@ -218,13 +499,29 @@ export class History {
218
499
  /** 마지막으로 읽을 때 깨져 있던 줄 수. 0 이 아니면 그만큼 못 되돌린다. */
219
500
  깨진줄 = 0;
220
501
 
502
+ /*
503
+ * 읽은 기록의 path 는 **지금 폴더**의 절대 경로로 펴서 준다 (위 「이력이 가리키는 곳」).
504
+ * /diff·/rewind 같은 부르는 쪽은 지금 폴더의 절대 경로로 기록을 찾는다 — 복사한 폴더에서
505
+ * 원본 폴더 경로가 나오면 그 파일을 「안 바꾼 파일」 로 본다.
506
+ * 밖을 가리키는 기록은 path 를 적힌 그대로 두고 `바깥` 표를 붙인다. 표는 파일에 안 적힌다.
507
+ */
221
508
  all() {
222
509
  if (!existsSync(this.file)) return [];
223
510
  let 깨진 = 0;
224
511
  const 것들 = readFileSync(this.file, 'utf8')
225
512
  .split('\n').filter(Boolean)
226
513
  .map((l) => { try { return JSON.parse(l); } catch { 깨진 += 1; return null; } })
227
- .filter(Boolean);
514
+ .filter((r) => {
515
+ if (r && typeof r === 'object' && !Array.isArray(r)) return true;
516
+ if (r !== null) 깨진 += 1; // JSON 이긴 한데 기록 꼴이 아니다
517
+ return false;
518
+ })
519
+ .map((r) => {
520
+ const 자리 = this.#자리풀기(r.path, r.rel);
521
+ const 이름자리 = r.이름되돌림 == null && r.이름되돌림rel == null ? undefined : this.#자리풀기(r.이름되돌림, r.이름되돌림rel);
522
+ if (!자리 || 이름자리 === null) return { ...r, 바깥: true };
523
+ return 이름자리 === undefined ? { ...r, path: 자리 } : { ...r, path: 자리, 이름되돌림: 이름자리 };
524
+ });
228
525
  this.깨진줄 = 깨진;
229
526
  return 것들;
230
527
  }
@@ -245,18 +542,57 @@ export class History {
245
542
  */
246
543
  undo(n = 1) {
247
544
  const recs = this.all();
248
- const turns = this.turns().slice(-n);
545
+ /*
546
+ * 0 · 음수는 「되돌릴 것이 없다」 는 말이다.
547
+ *
548
+ * `slice(-0)` 은 `slice(0)` 이라 **전부** 되돌아갔다. 지금 유일한 호출부(commands.js)가
549
+ * `/undo 0` 을 먼저 막고 있어 화면에는 안 나왔지만, 그건 부르는 쪽 한 자리가 지키는
550
+ * 것이지 이 함수가 지키는 것이 아니다 — 파일을 실제로 되돌리는 자리는 친 수보다 많이
551
+ * 도는 것이 제일 비싸다(commands.js 의 같은 잣대). 그래서 여기서도 막는다.
552
+ */
553
+ const 몇 = Math.floor(Number(n));
554
+ const turns = Number.isFinite(몇) && 몇 > 0 ? this.turns().slice(-몇) : [];
249
555
  if (!turns.length) return { restored: [], turns: 0, turnIds: [] };
250
556
 
251
557
  const target = recs.filter((r) => turns.includes(r.turn));
558
+ const restored = [];
559
+ /*
560
+ * 밖을 가리키는 기록은 **건드리지 않고 그렇다고 적는다** (파일 머리말 「이력이 가리키는 곳」).
561
+ * 되돌린 수에는 안 넣는다. 조용히 버리면 사람은 다 돌아온 줄 알고, 되돌린 척하면 더 나쁘다.
562
+ */
563
+ for (const r of target.filter((x) => x.바깥)) {
564
+ restored.push({ path: r.path, how: '그대로 둠 (작업 폴더 밖을 가리키는 기록이라 건드리지 않았습니다)', skipped: true, 바깥: true });
565
+ }
566
+ const 안것 = target.filter((x) => !x.바깥);
252
567
  // 같은 파일이 여러 번 바뀌었으면 가장 이른 상태로 되돌려야 한다.
568
+ // 이름 바꾸기 기록은 내용 기록이 아니다 — 따로 모아 맨 뒤에 되돌린다 (이름바꿈기록 머리말).
253
569
  const first = new Map();
254
- for (const r of target) if (!first.has(r.path)) first.set(r.path, r);
570
+ for (const r of 안것) if (!r.이름되돌림 && !first.has(r.path)) first.set(r.path, r);
571
+ const 이름바꿈들 = 안것.filter((r) => r.이름되돌림);
572
+ // 글자로는 안이어도 고리를 따라가면 밖일 수 있다. 뿌리는 한 번만 푼다.
573
+ const 진짜뿌리 = 진짜자리(this.root);
574
+ const 링크밖 = (p) => !안인가(진짜뿌리, 진짜자리(p));
575
+ const 링크밖이라둠 = (p) => ({ path: p, how: '그대로 둠 (링크를 따라가면 작업 폴더 밖이라 건드리지 않았습니다)', skipped: true, 바깥: true });
576
+ /*
577
+ * ── 못 뜬 채 **사라진** 파일이 있으면, 크기가 같은 새 바이너리는 지우지 않는다 (6회차 Gemini 도구6j) ──
578
+ *
579
+ * 옮기기는 떠난 자리를 뜨는데 그림은 못 떠서(skipped) 되돌릴 수 없고, 닿은 자리는 없던 자리라 아래에서 지운다 —
580
+ * 둘이 한 파일이면 그림이 한 벌도 안 남았다(Move · 셸 mv · 폴더째 옮기기 모두). 되돌릴 수 없는 쪽이 사라졌으면
581
+ * 남은 쪽이 그 파일의 마지막 한 벌일 수 있다. 크기를 안 적은 옛 기록이면 크기를 모르니 옮겨 왔을 수 있다고 본다.
582
+ * `cp img.png copy.png` 처럼 원본이 그대로 있으면 여기 안 걸려 사본은 4b 대로 지워진다.
583
+ */
584
+ const 사라진못뜬크기 = [...first.values()].filter((r) => r.skipped && !existsSync(r.path)).map((r) => r.크기 ?? null);
585
+ const 옮겨왔을수있나 = (p) => {
586
+ if (!사라진못뜬크기.length) return false;
587
+ if (사라진못뜬크기.includes(null)) return true;
588
+ try { return 사라진못뜬크기.includes(statSync(p).size); } catch { return false; }
589
+ };
255
590
 
256
- const restored = [];
257
591
  for (const [path, rec] of first) {
258
592
  try {
259
- if (rec.skipped) {
593
+ if (링크밖(path)) {
594
+ restored.push(링크밖이라둠(path));
595
+ } else if (rec.skipped) {
260
596
  // 내용을 못 떠 놓은 파일이다. 되돌릴 것이 없으니 손대지 않는다.
261
597
  // 조용히 넘기지는 않는다 — 되돌아간 줄 알면 사용자가 그 위에 계속 일한다.
262
598
  restored.push({ path, how: `그대로 둠 (${rec.skipped} — 내용을 떠 두지 못했습니다)`, skipped: true });
@@ -265,7 +601,17 @@ export class History {
265
601
  // 지금 디스크에 있는 것이 바이너리면 우리가 만든 파일일 리 없으니 안 지운다.
266
602
  if (!existsSync(path)) continue;
267
603
  let 바이너리 = false;
268
- try { 바이너리 = looksBinary(readFileSync(path)); } catch { 바이너리 = true; }
604
+ let 옮겨온것 = false;
605
+ // 「없던 자리」 표가 있는 파일은 이번 턴에 우리가 만든 것이라 바이너리여도 지운다 (snapshot 머리말).
606
+ // 폴더는 표가 있어도 옛 갈래로 둔다 — 안에 무엇이 들었는지 모르니 통째로 안 지운다.
607
+ if (rec.없던 && !statSync(path).isDirectory()) {
608
+ // 다만 못 뜬 채 사라진 파일을 옮겨 온 것일 수 있으면 안 지운다 (위 사라진못뜬크기).
609
+ if (옮겨왔을수있나(path)) { try { 옮겨온것 = looksBinary(readFileSync(path)); } catch { 옮겨온것 = true; } }
610
+ } else { try { 바이너리 = looksBinary(readFileSync(path)); } catch { 바이너리 = true; } }
611
+ if (옮겨온것) {
612
+ restored.push({ path, how: '그대로 둠 (바이너리 — 같은 턴에 못 뜬 채 사라진 파일과 크기가 같아, 옮겨 온 마지막 한 벌일 수 있어 지우지 않았습니다)', skipped: true });
613
+ continue;
614
+ }
269
615
  if (바이너리) {
270
616
  restored.push({ path, how: '그대로 둠 (바이너리 — 내용을 떠 두지 못했습니다)', skipped: true });
271
617
  continue;
@@ -288,6 +634,43 @@ export class History {
288
634
  restored.push({ path, how: `실패: ${err.message}`, ok: false, 못했나: true });
289
635
  }
290
636
  }
637
+ /*
638
+ * 이름은 내용을 다 되돌린 **뒤에**, 적은 반대 순서로 되돌린다.
639
+ *
640
+ * 대소문자만 다른 이름은 파일 시스템에게 같은 파일이라, 위에서 `b.txt` 로
641
+ * 쓴 내용은 지금 이름(`B.txt`)인 파일에 들어간다 — 그다음 이름만 바꾸면 된다.
642
+ * 이 턴에 만든 파일이면 위에서 이미 지워져 바꿀 것이 없다. 한 턴에 여러 번
643
+ * 바꿨으면(a → A → a) 늦게 바꾼 것부터 풀어야 처음 이름에 닿는다.
644
+ */
645
+ /*
646
+ * ── 이름이 부딪쳐 못 되돌린 것도 「못한 것」 이다 ──────────────────────
647
+ *
648
+ * 아래 셈(못한것)은 `ok === false` 만 셌다. 원래 이름 자리에 다른 파일이 있어 이름을
649
+ * 못 되돌린 갈래는 `skipped` 로만 적혀 그 셈에 안 들었고, 그 턴의 기록이 통째로 잘려
650
+ * 나갔다 — 부딪친 파일을 치우고 /undo 를 다시 쳐도 되돌릴 기록이 없다. 그건 **다시
651
+ * 해 보면 되는** 것이라 남긴다(내용을 애초에 못 뜬 갈래는 남겨도 영영 못 되돌리니
652
+ * 그대로 잘라낸다 — 되돌릴 것이 없는 줄을 남기면 그 턴이 영영 안 사라진다).
653
+ *
654
+ * 지금 이름을 따로 모으는 까닭: 이 갈래는 restored 에 **원래 이름**으로 적히는데
655
+ * 이력의 그 줄은 **지금 이름**으로 찾는다. 같은 칸에 넣으면 셈이 또 빗나간다.
656
+ */
657
+ const 다시할것 = new Set();
658
+ for (const rec of [...이름바꿈들].reverse()) {
659
+ try {
660
+ // 두 이름 중 하나라도 고리 너머 밖이면 이름을 안 바꾼다 — 바꾸면 밖의 파일이 옮겨진다.
661
+ if (링크밖(rec.path) || 링크밖(rec.이름되돌림)) { restored.push(링크밖이라둠(rec.이름되돌림)); continue; }
662
+ if (!existsSync(rec.path)) continue;
663
+ if (existsSync(rec.이름되돌림) && !같은물건(rec.path, rec.이름되돌림)) {
664
+ restored.push({ path: rec.이름되돌림, how: `그대로 둠 (원래 이름 자리에 다른 파일이 있어 ${basename(rec.path)} 의 이름을 못 되돌렸습니다)`, skipped: true });
665
+ 다시할것.add(rec.path); // 부딪친 것을 치우면 다시 된다 — 기록을 남긴다 (위 머리말)
666
+ continue;
667
+ }
668
+ renameSync(rec.path, rec.이름되돌림);
669
+ restored.push({ path: rec.이름되돌림, how: `이름 되돌림 (${basename(rec.path)} → ${basename(rec.이름되돌림)})`, ok: true });
670
+ } catch (err) {
671
+ restored.push({ path: rec.path, how: `실패: ${err.message}`, ok: false, 못했나: true });
672
+ }
673
+ }
291
674
  /*
292
675
  * ── 못 되돌린 것의 기록은 **남긴다** ──────────────────────────────
293
676
  *
@@ -300,7 +683,7 @@ export class History {
300
683
  * 되돌린 것만 잘라낸다. 못한 것은 그대로 두어 한 번 더 시도할 수 있게 한다.
301
684
  */
302
685
  const 못한것 = new Set(restored.filter((x) => x.ok === false).map((x) => x.path));
303
- const keep = recs.filter((r) => !turns.includes(r.turn) || 못한것.has(r.path));
686
+ const keep = recs.filter((r) => !turns.includes(r.turn) || 못한것.has(r.path) || 다시할것.has(r.path));
304
687
  /*
305
688
  * ── 여기가 터지면 **파일은 이미 되돌아가 있다** ────────────────────
306
689
  *
@@ -315,7 +698,7 @@ export class History {
315
698
  */
316
699
  let 이력줄임 = { ok: true };
317
700
  try {
318
- writeFileSync(this.file, keep.map((r) => JSON.stringify(r)).join('\n') + (keep.length ? '\n' : ''), 'utf8');
701
+ writeFileSync(this.file, keep.map(적을줄).join('\n') + (keep.length ? '\n' : ''), 'utf8');
319
702
  this.#잠갔나 = false; this.#잠그기();
320
703
  } catch (err) {
321
704
  이력줄임 = { ok: false, 왜: err?.message ?? String(err) };
@@ -354,6 +737,15 @@ export class History {
354
737
  * 평범한 소스 파일이 Read·Edit 은 되는데 스냅샷만 비는 상태가 됐다. 그러고 /undo 를
355
738
  * 누르면 멀쩡한 파일이 지워졌다. 두 곳의 잣대가 다르면 반드시 이런 틈이 생긴다.
356
739
  */
740
+ /** 두 경로가 디스크의 같은 물건인가 (dev·ino). 없거나 못 읽으면 아니다. */
741
+ function 같은물건(p, q) {
742
+ try {
743
+ const a = statSync(p, { bigint: true });
744
+ const b = statSync(q, { bigint: true });
745
+ return a.ino !== 0n && a.ino === b.ino && a.dev === b.dev;
746
+ } catch { return false; }
747
+ }
748
+
357
749
  function safeRead(p) {
358
750
  const buf = readFileSync(p);
359
751
  // 바이너리는 내용을 담지 않는다. '없던 파일' 과는 다른 값으로 알린다 —