deel-local-cli 2.0.1 → 2.1.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 (72) hide show
  1. package/README.ko.md +34 -7
  2. package/README.md +35 -7
  3. package/bin/deel.js +34 -1
  4. package/package.json +1 -1
  5. package/src/acp/serve.js +83 -17
  6. package/src/agent/commit.js +2 -2
  7. package/src/agent/compact.js +4 -4
  8. package/src/agent/donecheck.js +97 -0
  9. package/src/agent/grade.js +1 -1
  10. package/src/agent/loop.js +176 -13
  11. package/src/agent/memory.js +1 -1
  12. package/src/agent/models.js +1 -1
  13. package/src/agent/modes.js +3 -0
  14. package/src/agent/outschema.js +1 -1
  15. package/src/agent/pins.js +7 -2
  16. package/src/agent/project.js +1 -1
  17. package/src/agent/recall.js +1 -1
  18. package/src/agent/session.js +15 -0
  19. package/src/agent/store.js +13 -26
  20. package/src/backend/ctxsize.js +2 -2
  21. package/src/backend/http.js +1 -1
  22. package/src/backend/learn.js +27 -1
  23. package/src/backend/mcp.js +97 -16
  24. package/src/backend/proxy.js +1 -1
  25. package/src/backend/quota.js +2 -2
  26. package/src/commands.js +24 -2
  27. package/src/completion.js +16 -3
  28. package/src/config.js +33 -11
  29. package/src/configexplain.js +1 -1
  30. package/src/doctor.js +11 -1
  31. package/src/eval/run.js +432 -0
  32. package/src/eval/starter.js +95 -0
  33. package/src/i18n/en.js +20 -0
  34. package/src/i18n/ja.js +20 -0
  35. package/src/i18n/ko.js +20 -0
  36. package/src/i18n/zh.js +20 -0
  37. package/src/jsonl.js +42 -0
  38. package/src/lsp/client.js +6 -46
  39. package/src/oneshot.js +85 -3
  40. package/src/pack/selfpack.js +2 -2
  41. package/src/pack/zip.js +18 -1
  42. package/src/plugins/manage.js +81 -14
  43. package/src/reap.js +70 -0
  44. package/src/repl.js +85 -11
  45. package/src/report.js +1 -1
  46. package/src/safety/audit.js +4 -19
  47. package/src/safety/authcmd.js +1 -1
  48. package/src/safety/guard.js +56 -6
  49. package/src/safety/hooks.js +1 -1
  50. package/src/safety/keystore.js +18 -3
  51. package/src/safety/policy.js +38 -0
  52. package/src/safety/shellenv.js +1 -1
  53. package/src/safety/undo.js +44 -20
  54. package/src/skills/discover.js +1 -1
  55. package/src/tools/docs.js +3 -3
  56. package/src/tools/edit-match.js +1 -1
  57. package/src/tools/encoding.js +1 -1
  58. package/src/tools/excel-com.js +56 -30
  59. package/src/tools/fastgrep.js +1 -1
  60. package/src/tools/fsutil.js +8 -4
  61. package/src/tools/ignore.js +21 -1
  62. package/src/tools/index.js +122 -11
  63. package/src/tools/jobs.js +23 -4
  64. package/src/tools/shell.js +27 -0
  65. package/src/tools/todo.js +1 -1
  66. package/src/tools/webfetch.js +1 -1
  67. package/src/tools/xlsx.js +4 -4
  68. package/src/ui/approve.js +57 -3
  69. package/src/ui/banner.js +1 -1
  70. package/src/ui/inputbox.js +1 -1
  71. package/src/ui/md.js +1 -1
  72. package/src/ui/motion.js +1 -1
package/src/reap.js ADDED
@@ -0,0 +1,70 @@
1
+ /*
2
+ * 신호로 끝날 때 아이들을 거두는 **하나뿐인** 손 (2.0.2 · M3).
3
+ *
4
+ * ── 왜 한 자리로 모았나 ──────────────────────────────────────────────────
5
+ *
6
+ * 'exit' 그물(MCP 서버 · 일감 · 언어 서버가 저마다 건 것)은 **신호로 끝나면 안 돈다.**
7
+ * Node 는 SIGINT·SIGTERM 에 손이 없으면 그 자리에서 죽고, 그 길에는 'exit' 이 없다.
8
+ *
9
+ * 신호 손은 언어 서버(lsp/client.js)에만 있었고, 그 손은 **제 아이만** 거둔 뒤 같은 신호를
10
+ * 다시 보내 죽었다. 그래서 `kill <pid>` 한 번에 언어 서버는 거둬지고 MCP 서버와 일감
11
+ * (`npm run dev` 같은 것)은 남았다 — 입력이 닫히면 스스로 끝나는 서버만 운 좋게 사라졌다.
12
+ * 저마다 손을 따로 달면 더 나쁘다. 손이 둘이면 서로 「남이 맡고 있다」 고 보고 아무도 안
13
+ * 죽어서, deel 이 SIGTERM 을 **삼키는** 프로그램이 된다(아래 다시보낼까).
14
+ *
15
+ * 그래서 손은 여기 하나만 달고, 거둘 것은 각자 `신호에거두기()` 로 적는다.
16
+ *
17
+ * ── 치우고 나면 **원래대로 죽어야 한다** ────────────────────────────────
18
+ *
19
+ * Node 는 SIGINT·SIGTERM 에 손이 하나라도 달려 있으면 **기본 동작(끝내기)을 안 한다.**
20
+ * 치우기만 하고 말면 `kill <pid>` 에 아이들은 죽고 deel 은 그대로 산다 — 잡 관리자·컨테이너
21
+ * 종료·systemd 가 전부 이 신호로 정리하는데, 그것들이 하나같이 시간을 다 기다린 뒤 SIGKILL 로
22
+ * 때려잡게 된다. 그래서 치운 다음 우리 손을 떼고 같은 신호를 자신에게 다시 보낸다. 그러면
23
+ * 기본 동작이 돌아 128+n 로 끝난다 — 셸이 기대하는 그 값이다.
24
+ *
25
+ * 다만 **남이 그 신호를 맡고 있으면 안 건드린다.** 한 방 실행은 SIGINT 로 턴만 끊고 살아
26
+ * 있어야 하고(oneshot.js), 대화 화면의 Ctrl+C 는 readline 이 따로 받는다. 우리 것 말고 다른
27
+ * 손이 달려 있으면 그쪽 뜻이 먼저다 — 남이 맡은 신호로 결국 끝나면 그때는 'exit' 그물이 거둔다.
28
+ */
29
+
30
+ /** 신호로 끝날 때 부를 것들. 저마다 제 'exit' 그물과 같은 함수를 적는다. */
31
+ const 거둘것 = new Set();
32
+
33
+ /** 신호로 끝날 때 이것도 부른다. 같은 함수를 두 번 적어도 한 번만 돈다. */
34
+ export function 신호에거두기(손) { 거둘것.add(손); return 손; }
35
+
36
+ /** 검사가 본다 — 이 손이 적혀 있나. */
37
+ export function 거둘것에있나(손) { return 거둘것.has(손); }
38
+
39
+ /**
40
+ * 치운 다음 그 신호로 진짜 죽어야 하나.
41
+ *
42
+ * 갈림을 함수로 빼 둔 까닭: 윈도우에서는 이 길을 통째로 밟아 볼 수가 없다
43
+ * (SIGTERM 을 보내면 손이 돌기도 전에 Node 가 죽인다). 그런데 갈림 자체가
44
+ * 틀리면 유닉스에서 한 방 실행의 Ctrl+C 가 턴만 끊는 대신 프로그램을 죽인다 —
45
+ * 어느 판에서든 재어 볼 수 있어야 하는 자리다.
46
+ *
47
+ * @param 셈 이 신호에 달린 손의 수. 우리 것 하나만이면 우리가 마지막이다.
48
+ */
49
+ export function 다시보낼까(신호, 셈 = process.listenerCount(신호)) {
50
+ return Number(셈) <= 1;
51
+ }
52
+
53
+ /** 적힌 것을 다 부른다. 하나가 던져도 나머지는 부른다 — 끝나는 길이다. */
54
+ function 모두거두기() {
55
+ for (const 손 of 거둘것) { try { 손(); } catch { /* 끝나는 중이라 할 수 있는 게 없다 */ } }
56
+ }
57
+
58
+ for (const 신호 of ['SIGINT', 'SIGTERM']) {
59
+ const 손 = () => {
60
+ /*
61
+ * 갈림을 **거두기보다 먼저** 본다. 거두고 나서 갈랐었다 — 그래서 대화 화면의 Ctrl+C 가
62
+ * 턴만 끊고 세션은 사는데 언어 서버는 전부 죽고 풀도 비었다(2.0.0 6회차 사냥).
63
+ */
64
+ if (!다시보낼까(신호)) return; // 남이 맡고 있다 — 그쪽 뜻이 먼저다
65
+ 모두거두기();
66
+ process.removeListener(신호, 손);
67
+ try { process.kill(process.pid, 신호); } catch { process.exit(신호 === 'SIGINT' ? 130 : 143); }
68
+ };
69
+ process.on(신호, 손);
70
+ }
package/src/repl.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // 대화 화면. 루프가 보내는 이벤트를 Claude Code 풍으로 그린다.
2
2
  import { createInterface, emitKeypressEvents } from 'node:readline';
3
- import { 규칙모으기, 정책읽기 } from './safety/policy.js';
3
+ import { 규칙모으기, 정책읽기, 승인바닥 } from './safety/policy.js';
4
+ import { 검사설정 } from './agent/donecheck.js';
5
+ import { 자율대기 } from './ui/approve.js';
4
6
  import { 훅읽기 } from './safety/hooks.js';
5
7
  import { 에이전트읽기 } from './agent/agents.js';
6
8
  import { 받기설정 } from './safety/authcmd.js';
@@ -41,7 +43,7 @@ import { 배움 } from './agent/evolve.js';
41
43
  import { 마크다운 } from './ui/md.js';
42
44
  import { askHidden, 가림중 } from './ui/prompt.js';
43
45
  import { explain, shows as levelShows } from './ui/level.js';
44
- import { 고르기 as 승인고르기, 다음 as 승인다음 } from './ui/approve.js';
46
+ import { 고르기 as 승인고르기, 다음 as 승인다음, 미리보기줄들 } from './ui/approve.js';
45
47
  import { 추천, 채울글, 붙임탭완성기 } from './ui/complete.js';
46
48
  import { 접어쓰기 } from './ui/wrap.js';
47
49
  import { 접을까 as 붙임접을까, 표만들기 as 붙임표, 펼치기 as 붙임펼치기, 쓴번호들 as 붙임쓴번호들, 남길글, 안쪽최대 as 붙임안쪽최대 } from './ui/pastechip.js';
@@ -166,6 +168,8 @@ export const 이어쓰기표시 = (l) =>
166
168
 
167
169
  export async function chatLoop(opts = {}) {
168
170
  const cfg = load();
171
+ // 자율(auto) 모드에서 되묻기·계획 승인을 몇 초 기다리나 (ui/approve.js 의 자율대기). 설정의 askWait.
172
+ const 자율대기초 = 자율대기(cfg);
169
173
  // 모아 둔 소식을 비운다 (아래 「한 자리에서 비운다」 머리말).
170
174
  const 소식쓰기 = () => { for (const 줄 of 소식줄들(cfg)) 바로쓰기(줄); };
171
175
  const prof = activeProfile(cfg);
@@ -1042,11 +1046,14 @@ export async function chatLoop(opts = {}) {
1042
1046
  // Shift+Tab — 승인 방식 (자동 → 위험만 → 모두)
1043
1047
  if (key && key.name === 'tab' && key.shift) {
1044
1048
  const 앞 = 승인고르기(session.mode);
1045
- session.mode = 승인다음(session.mode);
1049
+ // 관리 정책의 바닥 위에서만 돈다 (ui/approve.js 의 다음).
1050
+ const 바닥 = 승인바닥();
1051
+ session.mode = 승인다음(session.mode, 바닥.바닥);
1046
1052
  const 뒤 = 승인고르기(session.mode);
1047
1053
  화면.입력지움();
1048
1054
  say(` ${뒤.색(뒤.글자)} ${c.bold(뒤.색(뒤.이름))} ${c.gray(뒤.한줄)}`);
1049
1055
  say(` ${c.gray(`${앞.이름} → ${뒤.이름} · Shift+Tab 으로 계속 바꿉니다`)}`);
1056
+ if (바닥.바닥 !== 'auto') say(` ${c.gray(옮긴말('approve.locked', { 바닥: 승인고르기(바닥.바닥).이름, 곳: 바닥.곳 ?? '' }))}`);
1050
1057
  prompt();
1051
1058
  return;
1052
1059
  }
@@ -1264,9 +1271,29 @@ export async function chatLoop(opts = {}) {
1264
1271
  * 있을 때만 「먼저 친 줄은 답이 아니다」 가 참이다.
1265
1272
  */
1266
1273
  const 미리쳐둔 = process.stdin.isTTY ? queue.splice(0) : [];
1267
- const 멈춤 = o.signal ?? turn?.signal ?? null;
1274
+ /*
1275
+ * 시한 (자율 모드 · ui/approve.js 의 자율대기). 주면 그 초가 지나 손을 뗀다 — ESC 와 같은 길이되,
1276
+ * 무엇으로 답한 셈 칠지는 따로 받는다(`시간끝`). ESC 는 여전히 ESC 로 읽힌다.
1277
+ */
1278
+ const 턴멈춤 = o.signal ?? turn?.signal ?? null;
1279
+ let 시간지남 = false;
1280
+ let 시계 = null;
1281
+ let 멈춤 = 턴멈춤;
1282
+ const 이어멈춤 = () => 시계줄?.abort();
1283
+ const 시계줄 = o.기다림초 > 0 ? new AbortController() : null;
1284
+ if (시계줄) {
1285
+ 멈춤 = 시계줄.signal;
1286
+ if (턴멈춤?.aborted) 시계줄.abort();
1287
+ else 턴멈춤?.addEventListener('abort', 이어멈춤, { once: true });
1288
+ 시계 = setTimeout(() => { 시간지남 = true; 시계줄.abort(); }, o.기다림초 * 1000);
1289
+ }
1268
1290
  try {
1269
1291
  const a = await nextLine(멈춤);
1292
+ if (시간지남) {
1293
+ say('');
1294
+ say(` ${c.gray(o.시간끝말 ?? 옮긴말('ask.timedOut', { 초: o.기다림초 }))}`);
1295
+ return o.시간끝 ?? o.멈추면 ?? '';
1296
+ }
1270
1297
  if (멈춤?.aborted) {
1271
1298
  // 물음이 화면에 걸린 채로 남으면 안 된다. 줄을 끊고 멈췄다고 적는다.
1272
1299
  say('');
@@ -1284,6 +1311,8 @@ export async function chatLoop(opts = {}) {
1284
1311
  if (a === null) return o.끝나면 ?? o.def ?? '';
1285
1312
  return a.trim() || o.def || '';
1286
1313
  } finally {
1314
+ clearTimeout(시계);
1315
+ 턴멈춤?.removeEventListener?.('abort', 이어멈춤);
1287
1316
  묻는중 = null;
1288
1317
  // 빼 뒀던 것을 앞에 되돌린다. 차례가 바뀌면 안 된다.
1289
1318
  if (미리쳐둔.length) queue.unshift(...미리쳐둔);
@@ -1369,6 +1398,8 @@ export async function chatLoop(opts = {}) {
1369
1398
  get 눈있나() { return !!conn.vision; },
1370
1399
  // 적어 둔 허락·금지 규칙 (safety/policy.js). 승인 모드보다 먼저 본다.
1371
1400
  규칙들: 규칙모으기(cfg),
1401
+ // 모델이 끝내려는 자리에서 돌릴 검사 (agent/donecheck.js). 설정에 check 가 없으면 null — 안 돈다.
1402
+ 완료검사: 검사설정(cfg),
1372
1403
  // 사람이 적어 둔 훅 (safety/hooks.js). 프로젝트 파일은 믿는 폴더에서만 읽는다.
1373
1404
  훅들: 훅정보.훅들,
1374
1405
  // 이름 붙인 하위 작업. 목록은 Task 스키마에, 지침은 고른 뒤에만 실린다.
@@ -1506,6 +1537,16 @@ export async function chatLoop(opts = {}) {
1506
1537
  */
1507
1538
  ask물음: async (물음, 고를것 = [], 이해 = '') => {
1508
1539
  if (closed) return null;
1540
+ /*
1541
+ * 자율(auto) 모드는 맡긴 모드다 — 사람 답을 끝없이 기다리지 않는다 (ui/approve.js 의 자율대기).
1542
+ * 0 이면 묻지 않고 넘긴다. 도구는 null 을 받으면 「스스로 판단해 이어가라」 고 모델에게 말한다.
1543
+ */
1544
+ const 대기 = session.mode === 'auto' ? 자율대기초 : 0;
1545
+ if (session.mode === 'auto' && 대기 === 0) {
1546
+ say('');
1547
+ say(` ${c.gray(옮긴말('ask.autoSkip', { 물음: clip(String(물음 ?? ''), 80) }))}`);
1548
+ return null;
1549
+ }
1509
1550
  const 폭 = Math.max(40, Math.min(88, (process.stdout.columns || 80) - 6)) - 4;
1510
1551
  say('');
1511
1552
  say(` ${c.hcyan('┌')} ${c.bold(옮긴말('ask.title'))}`);
@@ -1543,8 +1584,10 @@ export async function chatLoop(opts = {}) {
1543
1584
  * 아무도 안 고른 판에서 1번을 「사람이 골랐다」 로 모델에게 실어 보낸다.
1544
1585
  */
1545
1586
  const 답 = String(await ask(
1546
- 고를것.length ? 옮긴말('ask.pickPrompt', { 끝: 고를것.length }) : 옮긴말('ask.freePrompt'),
1547
- { def: 고를것.length ? '1' : '', 끝나면: '' },
1587
+ (고를것.length ? 옮긴말('ask.pickPrompt', { 끝: 고를것.length }) : 옮긴말('ask.freePrompt'))
1588
+ + (대기 ? ` ${c.gray(`(${옮긴말('ask.autoWait', { 초: 대기 })})`)}` : ''),
1589
+ // 시한이 지나면 안 고른 것이다 — def 인 '1' 로 떨어지면 아무도 안 고른 1번이 사람의 답이 된다.
1590
+ { def: 고를것.length ? '1' : '', 끝나면: '', 기다림초: 대기, 시간끝: '' },
1548
1591
  )).trim();
1549
1592
  창제목(제목글('도는중', { 폴더: 알림.폴더 }));
1550
1593
 
@@ -1568,9 +1611,11 @@ export async function chatLoop(opts = {}) {
1568
1611
  say('');
1569
1612
  return 고른것;
1570
1613
  },
1571
- confirm: async (name, args) => {
1614
+ confirm: async (name, args, { 미리보기 } = {}) => {
1572
1615
  say('');
1573
1616
  say(` ${c.yellow('?')} ${toolLabel(name, args)}`);
1617
+ // 무엇이 바뀌는지 먼저 보여 주고 묻는다 (ui/approve.js 의 미리보기줄들).
1618
+ for (const l of 미리보기줄들(미리보기, { 줄수: DIFF_LINES[session.level] ?? 40 })) say(l);
1574
1619
  /*
1575
1620
  * 여기서 종을 울린다. 끝난 것은 늦게 알아도 되지만 **막혀 있는 것은**
1576
1621
  * 기다린 만큼 그대로 손해다 — 다른 창에 가 있는 사이 3분째 이 줄에서
@@ -3006,6 +3051,26 @@ export async function chatLoop(opts = {}) {
3006
3051
  : ` ${c.gray('↺ 읽기만 하고 끝내려고 해서 한 번 되밀었습니다')}`);
3007
3052
  break;
3008
3053
 
3054
+ // 완료 검사 (agent/donecheck.js) — 모델이 끝내려 하자 deel 이 정해 둔 검사를 돌린다.
3055
+ case 'check_start':
3056
+ clearThinking();
3057
+ if (streamed) { 답비우기(); say(''); streamed = false; }
3058
+ say('');
3059
+ say(` ${c.cyan('⧗')} ${c.gray(옮긴말('check.start', { 판: ev.판, 최대: ev.최대, 명령: ev.명령 }))}`);
3060
+ break;
3061
+
3062
+ case 'check':
3063
+ clearThinking();
3064
+ if (ev.ok === true) say(` ${mark.ok} ${c.gray(옮긴말('check.pass', { 명령: ev.명령 }))}`);
3065
+ else if (ev.ok === false) {
3066
+ say(` ${c.red('✗')} ${c.white(옮긴말('check.fail', { 판: ev.판, 최대: ev.최대, 요약: ev.요약 }))}`);
3067
+ for (const 한줄 of String(ev.꼬리 ?? '').trim().split('\n').filter((l) => l.trim()).slice(-6)) {
3068
+ say(` ${c.gray(clip(한줄, 100))}`);
3069
+ }
3070
+ say(` ${c.gray(ev.판 < ev.최대 ? 옮긴말('check.retry') : 옮긴말('check.gaveUp', { 판: ev.판 }))}`);
3071
+ } else say(` ${c.yellow('⊘')} ${c.gray(옮긴말('check.skipped', { 까닭: ev.까닭 }))}`);
3072
+ break;
3073
+
3009
3074
  /*
3010
3075
  * 모델이 안 하겠다고 했다 (agent/loop.js 의 거절).
3011
3076
  *
@@ -3231,10 +3296,19 @@ export async function chatLoop(opts = {}) {
3231
3296
  * 승인받는 자리는 막아 놓고 계획 **전체**를 승인하는 자리가 열려 있었다.
3232
3297
  * 재 보니 아무도 답하지 않은 판에서 계획이 승인되고 파일이 만들어졌다.
3233
3298
  */
3234
- const 답 = String(await ask(
3235
- `${옮긴말('plan.ask')} ${c.gray(옮긴말('plan.hint'))}`,
3236
- { def: 'y', 끝나면: 'n', 멈추면: 'n' },
3237
- )).trim();
3299
+ /*
3300
+ * 자율(auto) 모드면 시한을 둔다 (ui/approve.js 의 자율대기). 지나면 **그대로 진행**이다 —
3301
+ * 이 상자는 안전 관문이 아니라 「계획부터 보여 준다」 는 자리이고, auto 를 고른 사람은
3302
+ * 맡긴 사람이다. 입력이 끝난 것(파이프·Ctrl+D)은 여전히 승인이 아니다(`끝나면: 'n'`).
3303
+ */
3304
+ const 계획대기 = session.mode === 'auto' ? 자율대기초 : 0;
3305
+ const 답 = session.mode === 'auto' && 계획대기 === 0
3306
+ ? (say(` ${c.gray(옮긴말('plan.autoGo'))}`), 'y')
3307
+ : String(await ask(
3308
+ `${옮긴말('plan.ask')} ${c.gray(옮긴말('plan.hint'))}`
3309
+ + (계획대기 ? ` ${c.gray(`(${옮긴말('ask.autoWait', { 초: 계획대기 })})`)}` : ''),
3310
+ { def: 'y', 끝나면: 'n', 멈추면: 'n', 기다림초: 계획대기, 시간끝: 'y', 시간끝말: 옮긴말('plan.timedOut', { 초: 계획대기 }) },
3311
+ )).trim();
3238
3312
  /*
3239
3313
  * ── 골라진 것 + 하고 싶은 말 ────────────────────────────────────
3240
3314
  *
package/src/report.js CHANGED
@@ -109,7 +109,7 @@ const VERDICT_STYLE = {
109
109
  };
110
110
 
111
111
  /** 판정을 사람 말로. 모르는 값이면 그대로 돌려준다 — 지어내지 않는다. */
112
- export function verdictName(level) {
112
+ function verdictName(level) {
113
113
  return VERDICT_NAME[level] ?? String(level ?? '');
114
114
  }
115
115
 
@@ -1,7 +1,8 @@
1
1
  // 감사 로그. 무엇을 언제 어떻게 했는지 전부 남긴다.
2
2
  // 자율 실행을 사내에 설득할 때 이 파일이 근거가 된다.
3
3
  import { join } from 'node:path';
4
- import { appendFileSync, mkdirSync, existsSync, readFileSync, chmodSync, statSync, openSync, readSync, closeSync } from 'node:fs';
4
+ import { appendFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
5
+ import { 줄로끝나나, 본인만잠그기 } from '../jsonl.js';
5
6
  import { 가리기 } from './secrets.js';
6
7
  import { 첫이름 } from '../tools/label.js';
7
8
 
@@ -126,25 +127,13 @@ export class Audit {
126
127
  * 개행이 아니면 개행부터 붙인다. 줄마다 재지 않는다 — 그 뒤로는 우리가 적은 줄이다.
127
128
  */
128
129
  #줄끝봤나 = false;
129
- #줄로끝나나() {
130
- let fd = null;
131
- try {
132
- const 크기 = statSync(this.file).size;
133
- if (!크기) return true;
134
- fd = openSync(this.file, 'r');
135
- const 한바이트 = Buffer.alloc(1);
136
- readSync(fd, 한바이트, 0, 1, 크기 - 1);
137
- return 한바이트[0] === 0x0a;
138
- } catch { return true; } // 없거나 못 읽으면 붙일 반쪽도 없다
139
- finally { if (fd != null) { try { closeSync(fd); } catch { /* 닫다 터져도 적기는 한다 */ } } }
140
- }
141
130
 
142
131
  write(kind, data) {
143
132
  const rec = { at: new Date().toISOString(), session: this.session, kind, ...data };
144
133
  try {
145
134
  let 줄 = JSON.stringify(rec) + '\n';
146
135
  if (!this.#줄끝봤나) {
147
- if (!this.#줄로끝나나()) 줄 = '\n' + 줄;
136
+ if (!줄로끝나나(this.file)) 줄 = '\n' + 줄;
148
137
  this.#줄끝봤나 = true;
149
138
  }
150
139
  appendFileSync(this.file, 줄, 'utf8');
@@ -180,11 +169,7 @@ export class Audit {
180
169
  #잠그기() {
181
170
  if (this.#잠갔나) return;
182
171
  this.#잠갔나 = true;
183
- // 윈도우에서 chmod 는 아무 일도 안 하고 **성공한다.** 그 성공을 적으면 위 머리말이 금지한
184
- // 「잠근 척」 이다 — agent/store.js 와 같이 안 걸었다고 적는다.
185
- if (process.platform === 'win32') { this.잠금 = { 못함: 'windows' }; return; }
186
- try { chmodSync(this.file, 0o600); this.잠금 = { 모드: 0o600 }; }
187
- catch (err) { this.잠금 = { 못함: err?.code ?? String(err) }; }
172
+ this.잠금 = 본인만잠그기(this.file);
188
173
  }
189
174
 
190
175
  /** 기록이 새고 있나. 새고 있으면 `{수, 까닭}`, 멀쩡하면 null. */
@@ -49,7 +49,7 @@ import { isOffline } from './network.js';
49
49
  import { 가리기 } from './secrets.js';
50
50
 
51
51
  /** 기다려 주는 시간. 사내 로그인은 브라우저를 열고 사람을 기다린다. */
52
- export const 기본기다림 = 180000;
52
+ const 기본기다림 = 180000;
53
53
 
54
54
  /** 수명을 안 알려 줄 때 몇 초짜리로 볼까. 짧게 잡는다 — 늦게 아는 것보다 낫다. */
55
55
  export const 기본수명 = 3300;
@@ -1,9 +1,9 @@
1
1
  // 자율 실행의 울타리.
2
2
  // 승인 프롬프트를 안 쓰는 대신 (1) 작업 범위 밖은 못 건드리고
3
3
  // (2) 되돌릴 수 없는 명령만 막는다. 나머지는 전부 통과시킨다.
4
- import { resolve, relative, isAbsolute, sep, dirname, basename } from 'node:path';
5
- import { realpathSync, existsSync } from 'node:fs';
6
- import { homedir } from 'node:os';
4
+ import { resolve, relative, isAbsolute, sep, dirname, basename, win32, posix } from 'node:path';
5
+ import { realpathSync, existsSync, readFileSync } from 'node:fs';
6
+ import { homedir, userInfo } from 'node:os';
7
7
  import { 내부살림 } from '../tools/fsutil.js';
8
8
 
9
9
  export class ScopeError extends Error {}
@@ -110,8 +110,28 @@ export function makeScope(root) {
110
110
  // Git Bash 꼴 절대 경로(/c/Users/…)는 윈도우에서 C:\Users\… 다. 안 풀면 지금
111
111
  // 드라이브 밑의 \c\Users\… 로 읽혀, 안에 있는 파일이 '범위 밖' 으로 막힌다.
112
112
  const 경로 = MSYS풀기(p);
113
- const abs = isAbsolute(경로) ? resolve(경로) : resolve(base, 경로);
114
- if (밖(base, abs)) throw new ScopeError(`작업 범위 밖입니다: ${p}\n 범위: ${base}`);
113
+ let abs = isAbsolute(경로) ? resolve(경로) : resolve(base, 경로);
114
+ /*
115
+ * ── 글자로는 밖인데 **같은 자리를 다르게 적은 것** (2.0.2 · A5) ──────────────
116
+ *
117
+ * 윈도우는 한 폴더를 두 이름으로 적는다 — `C:\Users\RUNNER~1\…`(8.3) 와
118
+ * `C:\Users\runneradmin\…`. 뿌리를 짧은 이름으로 들고 있는데 모델이 긴 이름
119
+ * 절대경로를 주면(셸의 pwd · 오류 글 · 딴 도구의 답에서 베낀 것), 여기가 글자로만
120
+ * 견줘 **같은 폴더의 파일을 「작업 범위 밖」 으로** 막았다. 재 봤다 —
121
+ * `DEEL_A~1` 뿌리에서 `deel_a5_longfoldername_…\b.txt` 쓰기가 막혔다.
122
+ *
123
+ * 그래서 글자로 밖이면 한 번 더 **실제 자리끼리** 견준다. 실제로도 밖이면 그대로
124
+ * 막는다. 실제로는 안이면 받아 들이되, 돌려주는 경로는 **뿌리가 적힌 꼴로** 다시
125
+ * 붙인다 — 되돌리기 기록(rel)·화면 경로(show)·커밋이 다 뿌리의 꼴로 견주기 때문이다.
126
+ * 긴 이름 그대로 넘기면 여기만 지나고 아래에서 같은 병이 다시 난다.
127
+ */
128
+ if (밖(base, abs)) {
129
+ const 실제 = 진짜자리(abs);
130
+ const 뿌리실제 = 진짜뿌리();
131
+ if (밖(뿌리실제, 실제)) throw new ScopeError(`작업 범위 밖입니다: ${p}\n 범위: ${base}`);
132
+ const 안쪽 = relative(같은꼴(뿌리실제), 같은꼴(실제));
133
+ abs = 안쪽 ? resolve(base, 안쪽) : base;
134
+ }
115
135
  // 글자로는 안인데 링크를 따라가면 밖인 자리. 정션 하나로 울타리가 열린다.
116
136
  //
117
137
  // 여기 견주기도 한 모양으로 맞춘다. 맥에서 realpathSync 는 디스크에 있는
@@ -560,6 +580,36 @@ export function checkPaths(cmd, scope) {
560
580
  return true;
561
581
  }
562
582
 
583
+ /*
584
+ * ── `~이름` 이 가리키는 집 (2.0.2 · 388) ────────────────────────────────────
585
+ *
586
+ * 「내 집 옆자리」 로만 풀었다. 그런데 root 로 돌면 내 집이 `/root` 라 옆자리가 **뿌리**다 —
587
+ * `~admin` 이 `/admin` 이 됐다. 셸은 passwd 의 `/home/admin` 으로 가는데. 작업 폴더가 마침
588
+ * `/admin` 이면 남의 집을 「범위 안」 으로 읽는다.
589
+ *
590
+ * 유닉스에서는 passwd 에 적힌 집을 먼저 본다(셸이 보는 그 자리). 없으면(맥의 디렉터리 서비스 ·
591
+ * 윈도) 예전처럼 내 집 옆자리로 풀되, 그 옆자리가 뿌리면 관례 자리(`/home`)로 간다. 내 이름이면
592
+ * 내 집이다.
593
+ */
594
+ export function 남의집(이름, { 집 = homedir(), 나 = null, passwd = null, platform = process.platform } = {}) {
595
+ let 내이름 = 나;
596
+ if (내이름 == null) { try { 내이름 = userInfo().username; } catch { 내이름 = ''; } }
597
+ if (이름 === 내이름) return 집;
598
+ if (platform !== 'win32') {
599
+ let 글 = passwd;
600
+ if (글 == null) { try { 글 = readFileSync('/etc/passwd', 'utf8'); } catch { 글 = ''; } }
601
+ for (const 줄 of String(글).split('\n')) {
602
+ const 칸 = 줄.split(':');
603
+ if (칸[0] === 이름 && 칸[5]) return 칸[5];
604
+ }
605
+ }
606
+ // 판을 넘겨받았으면 경로 셈도 그 판으로 한다. 리눅스에서 윈도 판을 물으면 `C:\Users\me` 가
607
+ // 상대경로로 읽혀 작업 폴더 밑이 됐다(검사만 그 판을 만든다 — 실제로는 늘 이 PC 의 판이다).
608
+ const 길 = platform === 'win32' ? win32 : posix;
609
+ const 옆 = 길.dirname(집);
610
+ return 길.resolve(옆 === 길.parse(옆).root ? 길.join(옆, 'home') : 옆, 이름);
611
+ }
612
+
563
613
  // 셸이 알아서 풀어 주는 자리표. 우리도 같이 풀어야 실제로 닿는 곳을 본다.
564
614
  function 자리표풀기(s) {
565
615
  return s
@@ -582,7 +632,7 @@ function 자리표풀기(s) {
582
632
  * 남의 집은 어느 쪽으로 풀든 내 작업 폴더 밖이다. 이름은 글자·밑줄로 시작할 때만 받는다
583
633
  * (`~1` · `~+` 은 셸의 다른 뜻). 가운데 물결(`./~backup` · `PROGRA~1`)은 `^` 에 안 닿는다.
584
634
  */
585
- .replace(/^~([A-Za-z_][\w.-]*)(?=[/\\]|$)/, (전체, 이름) => resolve(dirname(homedir()), 이름))
635
+ .replace(/^~([A-Za-z_][\w.-]*)(?=[/\\]|$)/, (전체, 이름) => 남의집(이름))
586
636
  /*
587
637
  * 파워셸은 `$env:이름` 으로 적는다.
588
638
  *
@@ -55,7 +55,7 @@ export const 제한기본 = 15000;
55
55
  /** 한 훅이 뱉을 수 있는 글의 최대. 넘으면 자르고 잘랐다고 말한다. */
56
56
  export const 글최대 = 16 * 1024;
57
57
  /** 한 자리에서 돌릴 수 있는 훅 수. 이보다 많으면 설정이 잘못된 것이다. */
58
- export const 훅최대 = 32;
58
+ const 훅최대 = 32;
59
59
 
60
60
  /**
61
61
  * 훅을 걸 수 있는 자리.
@@ -222,7 +222,7 @@ export function 잠그기(글, { 프로필id = null } = {}) {
222
222
 
223
223
  if (process.platform === 'win32') {
224
224
  const r = 파워셸실행(잠그는스크립트, Buffer.from(값, 'utf8').toString('base64'));
225
- if (r.ok && r.out) return `dpapi:${r.out}`;
225
+ if (r.ok && r.out) return 됐다(`dpapi:${r.out}`);
226
226
  못잠근까닭 = r.err || '파워셸이 잠그기를 못 마쳤습니다';
227
227
  해보고졌나 = true;
228
228
  return null;
@@ -230,7 +230,7 @@ export function 잠그기(글, { 프로필id = null } = {}) {
230
230
  if (process.platform === 'darwin') {
231
231
  const 이름 = 키체인이름(프로필id);
232
232
  const r = 키체인넣기(값, 이름);
233
- if (r.ok) return `keychain:${이름}`;
233
+ if (r.ok) return 됐다(`keychain:${이름}`);
234
234
  못잠근까닭 = r.err || 'security 가 열쇠를 못 넣었습니다';
235
235
  해보고졌나 = true;
236
236
  return null;
@@ -282,7 +282,22 @@ let 못잠근까닭 = null;
282
282
  */
283
283
  let 해보고졌나 = false;
284
284
 
285
- /** 마지막으로 잠그다 실패한 까닭. 한 번도 실패한 적이 없으면 null. */
285
+ /*
286
+ * ── 되고 나면 옛 실패를 **내려놓는다** (2.0.2 · 530) ──────────────────────
287
+ *
288
+ * 잠그기가 한 번 지면 그 까닭이 프로세스 내내 남았다. 뒤에 같은 판에서 잠그기가 되어도
289
+ * (파워셸이 잠깐 막혔다 풀림 · 키체인이 잠겼다 열림) 보관방식() 은 평문 값을 보면 여전히
290
+ * 「잠그려다 실패했습니다」 라고 적었다 — 지금은 되는 장치를 안 된다고 말하는 것이다.
291
+ * 되면 지운다. config.js 가 「몇 개를 왜 못 잠갔나」 에 쓰는 까닭은 그쪽이 **진 순간에**
292
+ * 받아 둔다(잠금옮기기).
293
+ */
294
+ function 됐다(값) {
295
+ 못잠근까닭 = null;
296
+ 해보고졌나 = false;
297
+ return 값;
298
+ }
299
+
300
+ /** 마지막으로 잠그다 실패한 까닭. 한 번도 실패한 적이 없으면(또는 그 뒤로 됐으면) null. */
286
301
  export function 잠그다실패한까닭() { return 못잠근까닭; }
287
302
 
288
303
  /** 사람이 스스로 껐나. 껐으면 이 판에서는 **아무것도 안 해 본다.** */
@@ -90,6 +90,44 @@ export function 정책읽기({ env = process.env, platform = process.platform,
90
90
  /** 검사가 원래대로 돌려놓을 때. */
91
91
  export function 정책잊기() { 읽은정책 = null; }
92
92
 
93
+ /*
94
+ * ── 승인 바닥 (2.1.0) ──────────────────────────────────────────────────
95
+ *
96
+ * 사내 검토에서 나온 물음: 「파일을 고치거나 지우기 전에 미리보기를 보여 주고
97
+ * 사람이 한 번 확인하는 절차가 **기본으로** 도는가.」
98
+ *
99
+ * 승인 모드는 쓰는 사람이 고른다(`/mode` · Shift+Tab). 기본은 auto 다 — 이 도구의
100
+ * 속도가 거기서 나온다. 그런데 관리자가 「이 PC 에서는 바꾸기 전에 늘 묻는다」 를
101
+ * 정할 자리가 없었다. 정책은 주소·봉인·금지만 받았다.
102
+ *
103
+ * { "approval": "strict" }
104
+ *
105
+ * 정책의 approval 은 **바닥**이다. 사람은 그보다 더 조일 수는 있어도 아래로는 못
106
+ * 내린다 — 정책이 넓히지 못하는 것과 같은 자세다. 모르는 값이면 제일 좁은
107
+ * strict 로 친다. 관리자가 오타를 냈다고 바닥이 통째로 빠지면, 관리자는 걸린 줄
108
+ * 알고 사람은 안 걸린 채로 쓴다.
109
+ */
110
+ export const 승인차례 = ['auto', 'confirm', 'strict'];
111
+
112
+ export function 승인바닥(읽은 = 정책읽기()) {
113
+ /*
114
+ * 파일이 있는데 **못 읽었으면**(쉼표 하나 틀린 JSON) 바닥도 strict 다. 위의 「모르는 값」 과
115
+ * 같은 까닭이다 — 관리자는 `"approval": "strict",` 를 적어 두었고 걸린 줄 안다. 못 읽었다고
116
+ * auto 로 풀면 오타 하나로 바닥이 통째로 빠진다 (2차 눈 판정). 파일이 없는 PC 는 그대로 auto.
117
+ */
118
+ if (읽은?.탈) return { 바닥: 'strict', 곳: 읽은.곳 ?? null, 모름: null, 탈: 읽은.탈 };
119
+ const v = 읽은?.값?.approval;
120
+ if (v === undefined || v === null || v === '') return { 바닥: 'auto', 곳: null, 모름: null };
121
+ const s = String(v).trim().toLowerCase();
122
+ if (승인차례.includes(s)) return { 바닥: s, 곳: 읽은.곳, 모름: null };
123
+ return { 바닥: 'strict', 곳: 읽은.곳, 모름: String(v) };
124
+ }
125
+
126
+ /** 둘 중 더 많이 묻는 쪽. 모르는 이름은 auto 로 친다 — 바닥 쪽이 따로 막는다. */
127
+ export function 더센승인(a, b) {
128
+ return 승인차례[Math.max(0, 승인차례.indexOf(a), 승인차례.indexOf(b))];
129
+ }
130
+
93
131
  /*
94
132
  * `Tool(무늬)` 를 읽는다.
95
133
  *
@@ -116,7 +116,7 @@ function 이름마디들(이름) {
116
116
  const 자격든주소 = /^[a-z][a-z0-9+.-]*:\/\/[^\s/?#@:]*:[^\s/?#@]+@/i;
117
117
 
118
118
  /** 이 값이 자격이 든 주소인가. 이름으로는 못 거르는 DATABASE_URL 같은 것을 거른다. */
119
- export function 비밀값인가(값) {
119
+ function 비밀값인가(값) {
120
120
  return typeof 값 === 'string' && 자격든주소.test(값.trim());
121
121
  }
122
122
 
@@ -1,7 +1,8 @@
1
1
  // 되돌리기. 승인 프롬프트를 안 쓰는 대신 이게 안전망이다.
2
2
  // 파일을 고치기 전에 항상 이전 내용을 떠 놓고, /undo 로 턴 단위로 되돌린다.
3
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';
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, rmdirSync, appendFileSync, statSync, renameSync, realpathSync } from 'node:fs';
5
+ import { 줄로끝나나, 본인만잠그기 } from '../jsonl.js';
5
6
  import { looksBinary } from '../tools/encoding.js';
6
7
  import { 살림폴더만들기 } from './audit.js';
7
8
  import { 말 } from '../i18n/index.js';
@@ -86,6 +87,19 @@ export class History {
86
87
  this.잠금 = null;
87
88
  }
88
89
 
90
+ /** 이 경로의 조상 가운데 **아직 없는** 폴더들(뿌리 기준 상대 경로, 깊은 것부터). 뿌리 밖으로는 안 올라간다. */
91
+ #없는폴더들(abs) {
92
+ const 들 = [];
93
+ let d = dirname(resolve(String(abs)));
94
+ for (let i = 0; i < 64 && !existsSync(d); i++) {
95
+ const 상대 = this.#상대(d);
96
+ if (상대 === null) break;
97
+ 들.push(상대);
98
+ d = dirname(d);
99
+ }
100
+ return 들;
101
+ }
102
+
89
103
  /** 새 기록에 적을 상대 경로. 뿌리 밖이면 null — 그런 기록은 되돌릴 때 안 받는다. */
90
104
  #상대(abs) {
91
105
  const a = resolve(String(abs));
@@ -120,23 +134,11 @@ export class History {
120
134
  * 개행으로 끝내므로 한 번 본 것이 계속 맞다.
121
135
  */
122
136
  #줄끝봤나 = 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
137
 
136
138
  #덧붙이기(rec) {
137
139
  let 줄 = 적을줄(rec) + '\n';
138
140
  if (!this.#줄끝봤나) {
139
- if (!this.#줄로끝나나()) 줄 = '\n' + 줄;
141
+ if (!줄로끝나나(this.file)) 줄 = '\n' + 줄;
140
142
  this.#줄끝봤나 = true;
141
143
  }
142
144
  appendFileSync(this.file, 줄, 'utf8');
@@ -166,11 +168,7 @@ export class History {
166
168
  #잠그기() {
167
169
  if (this.#잠갔나) return;
168
170
  this.#잠갔나 = true;
169
- // 윈도우에서 chmod 는 아무 일도 안 하고 **성공한다.** 그 성공을 적으면 위 머리말이 금지한
170
- // 「잠근 척」 이다 — agent/store.js 와 같이 안 걸었다고 적는다.
171
- if (process.platform === 'win32') { this.잠금 = { 못함: 'windows' }; return; }
172
- try { chmodSync(this.file, 0o600); this.잠금 = { 모드: 0o600 }; }
173
- catch (err) { this.잠금 = { 못함: err?.code ?? String(err) }; }
171
+ this.잠금 = 본인만잠그기(this.file);
174
172
  }
175
173
 
176
174
  /**
@@ -236,6 +234,22 @@ export class History {
236
234
  * 그림·문서가 /undo 뒤에도 남았다. 이 표가 있으면 정말 없던 자리라 바이너리여도 지운다.
237
235
  */
238
236
  if (없던) rec.없던 = true;
237
+ /*
238
+ * ── 이 파일과 **같이 생길 폴더**도 적는다 (2.0.2 · B3) ─────────────────────
239
+ *
240
+ * 폴더를 통째로 옮긴 판(`dirA` → `dirB`)을 되돌리면 파일은 `dirA` 로 돌아오고
241
+ * `dirB/f.txt` 는 「원래 없던 파일」 로 지워지는데, **빈 `dirB` 가 남았다.** 되돌린
242
+ * 뒤의 폴더가 되돌리기 전과 다르다. 그 폴더가 이번에 생겼다는 것을 아무 데도
243
+ * 안 적어서 지울 근거가 없었다.
244
+ *
245
+ * 원래 있던 빈 폴더에 파일을 만든 판과 가르려면 **뜰 때** 봐야 한다 — 되돌릴 때는
246
+ * 둘 다 「빈 폴더」 라 구별이 안 된다. 그래서 여기서 아직 없는 조상 폴더를 깊은
247
+ * 것부터 적어 두고, 되돌릴 때 그 폴더가 **비었을 때만** 지운다.
248
+ */
249
+ if (없던) {
250
+ const 새폴더 = this.#없는폴더들(absPath);
251
+ if (새폴더.length) rec.새폴더 = 새폴더;
252
+ }
239
253
  // 작업 폴더 기준 자리. 폴더를 옮기거나 복사해도 이 이력은 **그 폴더**를 되돌린다 (위 머리말).
240
254
  const 상대 = this.#상대(absPath);
241
255
  if (상대 !== null) rec.rel = 상대;
@@ -618,6 +632,16 @@ export class History {
618
632
  }
619
633
  rmSync(path, { force: true });
620
634
  restored.push({ path, how: 말('undo.wayDeleted'), ok: true });
635
+ // 그 파일과 같이 생긴 폴더가 이제 비었으면 거둔다 (snapshot 의 새폴더 머리말). rmdirSync 는 **빈
636
+ // 폴더만** 지운다 — 그 뒤에 누가 넣은 것이 있으면 ENOTEMPTY 로 멈추고, 폴더가 아니면 ENOTDIR 다.
637
+ // 이력은 저장소에 딸려 올 수 있으니(파일 머리말) 폴더 자리도 밖·링크 너머면 안 건드린다.
638
+ // 파일이 아니라서 되돌린 수에는 안 센다.
639
+ for (const 상대 of rec.새폴더 ?? []) {
640
+ const d = this.#자리풀기(null, 상대);
641
+ if (!d || 링크밖(d)) break;
642
+ try { rmdirSync(d); } catch { break; }
643
+ restored.push({ path: d, how: '빈 폴더 지움 (이번에 새로 생긴 폴더)', ok: true, 폴더: true });
644
+ }
621
645
  } else {
622
646
  /*
623
647
  * 담고 있던 폴더가 없어졌을 수 있다 — Move 로 폴더째 옮긴 경우다.
@@ -710,7 +734,7 @@ export class History {
710
734
  // 이력에서 못 읽은 줄 수. 그만큼은 되돌릴 길이 애초에 없었다.
711
735
  깨진줄: this.깨진줄,
712
736
  // 화면·감사기록이 쓰는 수. **진짜로 되돌아간 것만** 센다.
713
- 되돌린수: restored.filter((x) => x.ok === true).length,
737
+ 되돌린수: restored.filter((x) => x.ok === true && !x.폴더).length,
714
738
  못한것: restored.filter((x) => x.ok === false),
715
739
  turns: turns.length,
716
740
  turnIds: turns.slice(),
@@ -38,7 +38,7 @@ export const 내장자리 = join(dirname(fileURLToPath(import.meta.url)), 'built
38
38
  * 앞머리가 몇 백 줄일 까닭은 없어서 찾는 줄 수에도 상한을 둔다. 같은 열쇠가 두 번 오면
39
39
  * 첫 것을 둔다 — 뒤엣것은 대개 본문이 새어 들어온 것이다.
40
40
  */
41
- export const 앞머리최대줄 = 200;
41
+ const 앞머리최대줄 = 200;
42
42
  const 금인가 = (줄) => /^---[ \t]*\r?$/.test(줄);
43
43
  // YAML 앞머리에 설 수 있는 줄 꼴 — 빈 줄 · 들여 쓴 줄 · 주석 · 목록 · `열쇠:` 줄.
44
44
  const 앞머리줄인가 = (줄) => {