deel-local-cli 1.17.3 → 1.17.5

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/src/agent/loop.js CHANGED
@@ -12,7 +12,7 @@ import { 살린쓰기 } from './salvage.js';
12
12
  import { 배울것, 길이문제인가 } from '../backend/learn.js';
13
13
  import { compact, shouldCompact, shouldFold, foldToolResults, foldImages, 못박을것, 접힌파일열쇠 } from './compact.js';
14
14
  import { 걸음수, 하위걸음수, 요약길이 } from './budget.js';
15
- import { Session } from './session.js';
15
+ import { Session, estimateTokens } from './session.js';
16
16
  import { 최대깊이, 하위모드, 하위요약 } from '../tools/task.js';
17
17
  import { 찾기 as 에이전트찾기, 할일합치기, 도구줄이기 } from './agents.js';
18
18
  import { 프로필찾기, 쓸수있나, 연결만들기, 알릴말, 목록보기 } from './models.js';
@@ -20,6 +20,9 @@ import { allowTemporarily, isOffline } from '../safety/network.js';
20
20
  import { 가리기, 훑기, 가렸다는말, 봤다는말, 가릴까 } from '../safety/secrets.js';
21
21
  import { 바깥인가 } from '../safety/runmode.js';
22
22
  import { get as workMode } from './modes.js';
23
+ // 종합 모드에서 단계가 일을 따라간다 (agent/단계.js).
24
+ import { 다음단계 } from './단계.js';
25
+ import { 묻지말라했나, 손대라했나 } from './route.js';
23
26
  import { 지시말 } from '../i18n/index.js';
24
27
  import { 빠진것, 빠졌다는말 } from './asks.js';
25
28
  import { 환경속열쇠들 } from '../config.js';
@@ -102,14 +105,46 @@ function thinkFor(conn, level) {
102
105
  return level;
103
106
  }
104
107
 
105
- // 아무것도 안 바꾸는 도구들. 이것들만 동시에 돌린다.
106
- //
107
- // 왜 이것만인가:
108
- // Read 개를 동시에 하는 것은 안전하다 — 서로 안 건드린다.
109
- // Write·Edit 동시에 돌리면 같은 파일을 갈래로 고칠 수 있고,
110
- // 되돌리기 스냅샷 순서도 뒤엉킨다. Bash 무슨 짓을 할지 없다.
111
- // 그래서 '읽기만 하는 것' 이라고 확실한 도구만 묶는다.
112
- const 읽기전용 = new Set(['Read', 'Glob', 'Grep', 'Skill', 'WebFetch']);
108
+ /*
109
+ * 아무것도 안 바꾸는 도구들. **이것들만** 동시에 돌린다.
110
+ *
111
+ * 이것만인가:
112
+ * Read 개를 동시에 하는 것은 안전하다 서로 건드린다.
113
+ * Write·Edit 동시에 돌리면 같은 파일을 갈래로 고칠있고,
114
+ * 되돌리기 스냅샷 순서도 뒤엉킨다. Bash 무슨 짓을 할지 알 수 없다.
115
+ * 그래서 '읽기만 하는 ' 이라고 확실한 도구만 묶는다.
116
+ *
117
+ * ── 그런데 이 목록이 modes.js 의 읽기 갈래보다 **짧았다** ───────────────
118
+ *
119
+ * modes.js 는 읽기만 하는 도구를 열 개로 세는데(Read·Outline·Glob·Grep·Def·
120
+ * Refs·WebFetch·Skill·Recall·Ask), 여기는 다섯 개만 알고 있었다. 나머지는
121
+ * 「안전한지 모르겠다」 가 아니라 그냥 **빠져 있었다.**
122
+ *
123
+ * 값이 큰 자리다. 시킴말이 남의 코드를 볼 때 **제일 먼저 부르라고 적어 둔
124
+ * 도구가 Outline** 이고(modes.js 의 읽는법), 그게 여태 한 개씩 돌았다.
125
+ * Def·Refs 는 언어 서버 왕복이라 기다리는 시간이 곧 사람이 기다리는 시간인데
126
+ * 그것도 한 줄로 섰다. 파일 여섯 개의 모양을 보는 데 여섯 번을 왕복했다.
127
+ *
128
+ * Ask 만 뺀다. 그건 사람에게 묻는 도구다 — 셋을 한꺼번에 물으면 화면에
129
+ * 물음 셋이 겹쳐 뜨고, 어느 답이 어느 물음의 것인지 사람이 못 가른다.
130
+ * 읽기 전용인 것은 맞지만 **사람의 차례를 쓰는 도구**라 줄을 세워야 한다.
131
+ */
132
+ const 읽기전용 = new Set(['Read', 'Outline', 'Glob', 'Grep', 'Def', 'Refs', 'Recall', 'Skill', 'WebFetch']);
133
+
134
+ /*
135
+ * 잘린 답을 「생각을 줄여」 다시 부를 때 쓰는 두 값.
136
+ *
137
+ * 눈금 한 칸을 내리면 생각 예산은 대략 네 할이 준다(agent/effort.js 의 배분).
138
+ * 그렇게 되찾은 자리가 **1,024토큰은 돼야** 답이 한 문단이라도 더 나온다.
139
+ * 그 아래에서 다시 부르면 같은 자리에서 또 잘리는데, 그 한 번이 프리픽스
140
+ * 전체와 출력 상한 전체를 다시 낸다.
141
+ *
142
+ * 상한에 견준 비율(예전의 15%)로 안 재는 까닭: 같은 15% 가 16k 상한에서는
143
+ * 2,400토큰이고 2k 상한에서는 300토큰이다. 같은 잣대인 척하면서 실제로는
144
+ * 상한이 클수록 헐거워진다.
145
+ */
146
+ export const 되찾을몫 = 0.4;
147
+ export const 쓸모있는자리 = 1024;
113
148
 
114
149
  /**
115
150
  * 호출 목록을 '같이 돌려도 되는 덩어리' 로 자른다.
@@ -399,7 +434,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
399
434
  * 여전히 설계 모드라고 떠 있는 채로 파일이 바뀐다. task.js 의 하위모드() 가
400
435
  * 모드 쪽에서 한 겹 막고, 여기가 도구 쪽에서 한 겹 더 막는다.
401
436
  */
402
- const tools = toolSchemas(session.도구제한 ?? null, {
437
+ let tools = toolSchemas(session.도구제한 ?? null, {
403
438
  hasSkills: (session.skills?.length ?? 0) > 0,
404
439
  web: session.web !== false && !isOffline(), // 오프라인이면 웹 도구는 아예 안 보여 준다
405
440
  work: session.effectiveWork(), // 작업 모드가 쓰는 것만 (modes.js)
@@ -425,17 +460,17 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
425
460
  * 이름은 그 표에 없어서 undefined 를 읽다 죽는다. MCP 는 이름 목록이 아니라
426
461
  * ctx.mcp 로 따로 넘어가므로 하위도 그 길로 똑같이 받는다.
427
462
  */
428
- const 내도구 = tools.map((t) => t.function.name).filter((n) => !n.startsWith('mcp__'));
463
+ let 내도구 = tools.map((t) => t.function.name).filter((n) => !n.startsWith('mcp__'));
429
464
  /*
430
465
  * 깊이 상한. 부모(0) → 하위(1) → 하위의 하위(2) 까지다.
431
466
  *
432
467
  * 상한에 닿으면 목록에서 Task 를 뺀다. "더 쪼개지 마라" 고 부탁하지 않는다 —
433
468
  * 모델은 부탁을 잊고, 잊으면 하위가 하위를 끝없이 낳는다.
434
469
  */
435
- const 자식도구 = 깊이 + 1 >= 최대깊이 ? 내도구.filter((n) => n !== 'Task') : 내도구;
470
+ let 자식도구 = 깊이 + 1 >= 최대깊이 ? 내도구.filter((n) => n !== 'Task') : 내도구;
436
471
  // 모드마다 생각의 배분과 걸음 수가 다르다. 사용자가 따로 정했으면 그걸 존중한다.
437
- const 모드 = workMode(session.effectiveWork());
438
- const effort = session.effortSet ? session.effort : (모드.effort ?? session.effort);
472
+ let 모드 = workMode(session.effectiveWork());
473
+ let effort = session.effortSet ? session.effort : (모드.effort ?? session.effort);
439
474
  /*
440
475
  * ── 강도는 **천장**이고, 이번 턴 값은 시킨 말이 정한다 (agent/effort.js) ──
441
476
  *
@@ -457,8 +492,8 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
457
492
  * 그래서 파일 열 개를 읽고 경합 조건을 짚어야 하는 일까지 low 로 돌았다 —
458
493
  * 생각 블록이 0자로 나가고, 답이 그만큼 얕았다.
459
494
  */
460
- const 천장 = session.thinkSet ? session.think : 천장고르기(모드.think, session.think, userText);
461
- const think = 자동강도(userText, 천장, {
495
+ let 천장 = session.thinkSet ? session.think : 천장고르기(모드.think, session.think, userText);
496
+ let think = 자동강도(userText, 천장, {
462
497
  대화크기: session.messages.length,
463
498
  켜짐: session.autoThink !== false,
464
499
  });
@@ -477,7 +512,92 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
477
512
  * 없다 — 8k 모델에는 너무 크고, 655k 모델은 여유가 96% 남았는데도 만들다
478
513
  * 만 채로 끊겼다. 사람이 직접 준 값이 있으면 그것이 먼저다.
479
514
  */
480
- const maxSteps = session.stepsSet ? session.maxSteps : 걸음수(모드.id, conn.ctx);
515
+ let maxSteps = session.stepsSet ? session.maxSteps : 걸음수(모드.id, conn.ctx);
516
+ /*
517
+ * ── 단계가 일을 따라간다 (agent/단계.js) ────────────────────────────────
518
+ *
519
+ * 종합 모드는 한마디를 보고 단계를 고르는데, 그 고르기가 **턴이 시작할 때 딱
520
+ * 한 번** 돌았다. 마흔 걸음짜리 턴에서 일의 성격은 몇 번씩 바뀌는데, 시킴말은
521
+ * 첫 걸음의 것 그대로였다 — 원인을 다 찾고 고치는 중인데도 화면과 시킴말은
522
+ * 끝까지 「디버그」 였다.
523
+ *
524
+ * 여기서 세는 것은 **이미 일어난 일**이다. 모델에게 「지금 무슨 단계냐」 고
525
+ * 한 번 더 묻지 않는다. 그러면 걸음마다 왕복이 하나 늘고, 그 판단이 더
526
+ * 나을 까닭도 없다.
527
+ */
528
+ const 이번턴본것 = { 할일: false, 바꿈: false };
529
+ const 묻지말라 = 묻지말라했나(userText);
530
+ // 계획에서 넘어갈 때만 본다. 「묻지 마라」 는 **어떻게**의 말이고,
531
+ // 「고쳐라」 는 **무엇을**의 말이다. 범위를 정하는 것은 뒤엣것이다(단계.js).
532
+ const 손대라 = 손대라했나(userText);
533
+ const 종합인가 = session.work === 'auto';
534
+
535
+ /**
536
+ * 단계를 옮겨야 하면 옮기고, 옮겼으면 알린다.
537
+ *
538
+ * 시킴말은 저절로 따라온다 — session.wire() 가 effectiveWork() 를 매번 보고
539
+ * 짓기 때문이다(agent/session.js). 여기서 다시 지어야 하는 것은 **도구와
540
+ * 걸음 수와 생각의 배분**이다.
541
+ *
542
+ * @returns {object|null} 알릴 것이 있으면 이벤트, 없으면 null
543
+ */
544
+ const 단계옮기기 = () => {
545
+ const 갈곳 = 다음단계({ 지금: 모드.id, 종합인가, 본것: 이번턴본것, 묻지말라, 손대라 });
546
+ if (!갈곳) return null;
547
+ const 옛모드 = 모드.id;
548
+ session.routed = 갈곳;
549
+ 모드 = workMode(갈곳);
550
+
551
+ /*
552
+ * 도구가 늘면 **앞머리가 바뀐다.** 규격이 tools 를 맨 앞에 렌더하므로,
553
+ * 도구 하나가 늘어도 그 턴의 캐시는 거기서 끊긴다. 값이 드는 일이라
554
+ * 조용히 하지 않는다 — 화면에 적는다.
555
+ */
556
+ const 옛도구 = new Set(내도구);
557
+ tools = toolSchemas(session.도구제한 ?? null, {
558
+ hasSkills: (session.skills?.length ?? 0) > 0,
559
+ web: session.web !== false && !isOffline(),
560
+ work: session.effectiveWork(),
561
+ mcp: ctx.mcp ?? null,
562
+ 에이전트들: 깊이 + 1 >= 최대깊이 ? null : (ctx.에이전트들 ?? null),
563
+ lsp: session.lsp === true,
564
+ ctx: conn.ctx ?? null,
565
+ vision: conn.vision === true,
566
+ });
567
+ 내도구 = tools.map((t) => t.function.name).filter((n) => !n.startsWith('mcp__'));
568
+ 자식도구 = 깊이 + 1 >= 최대깊이 ? 내도구.filter((n) => n !== 'Task') : 내도구;
569
+ const 늘어난것 = 내도구.filter((n) => !옛도구.has(n));
570
+
571
+ // 생각의 배분도 그 단계 것으로. 사람이 직접 정했으면 그것이 먼저다.
572
+ if (!session.effortSet) effort = 모드.effort ?? session.effort;
573
+ if (!session.thinkSet) {
574
+ 천장 = 천장고르기(모드.think, session.think, userText);
575
+ think = 자동강도(userText, 천장, {
576
+ 대화크기: session.messages.length,
577
+ 켜짐: session.autoThink !== false,
578
+ });
579
+ }
580
+ /*
581
+ * 걸음 수는 **늘리기만** 한다.
582
+ *
583
+ * 계획 모드의 상한은 코드 모드보다 작다. 넘어가면서 그 작은 값을 그대로
584
+ * 들고 가면 만들다 말고 끊긴다. 반대로 줄이면 이미 지난 걸음 수보다 작아져
585
+ * 그 자리에서 턴이 끝나 버릴 수도 있다 — 어느 쪽으로도 줄일 이유가 없다.
586
+ */
587
+ if (!session.stepsSet) maxSteps = Math.max(maxSteps, 걸음수(모드.id, conn.ctx));
588
+
589
+ return {
590
+ type: '단계옮김',
591
+ 옛: 옛모드,
592
+ 새: 갈곳,
593
+ // 화면 글이 아니라 **까닭 이름**을 낸다. 옮겨 적는 것은 화면의 몫이다
594
+ // (i18n/*.js 의 ev.phaseWhy*). 여기서 한국어를 내면 영어 화면에 섞인다.
595
+ 왜: 이번턴본것.바꿈 ? '바꿈' : '할일',
596
+ 늘어난도구: 늘어난것,
597
+ 걸음: maxSteps,
598
+ };
599
+ };
600
+
481
601
  const attempted = new Set(); // 같은 변경성 명령을 두 번 실행하지 않기 위한 기록
482
602
  let steps = 0;
483
603
  let lastToolFailed = false; // 직전 단계에서 도구가 오류를 냈나 → 다음 판단은 세게
@@ -673,6 +793,15 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
673
793
  while (steps < maxSteps) {
674
794
  steps++;
675
795
 
796
+ /*
797
+ * 단계를 **부르기 전에** 다시 본다 (위 단계옮기기).
798
+ *
799
+ * 부른 뒤에 옮기면 이번 걸음은 옛 시킴말로 나가고, 옮긴 보람이 한 걸음
800
+ * 늦는다. 마흔 걸음짜리 턴에서 그 한 걸음이 파일 내용 한 벌씩이다.
801
+ */
802
+ const 옮김 = 단계옮기기();
803
+ if (옮김) yield 옮김;
804
+
676
805
  // 부르기 **전에** 자리를 본다. 왜 여기인지는 자리만들기() 머리말에.
677
806
  yield* 자리만들기();
678
807
  if (접다멈췄나) return;
@@ -803,6 +932,38 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
803
932
  try {
804
933
  yield* askModel(cap, level);
805
934
 
935
+ /*
936
+ * ── 게이트웨이가 이번 요청을 **다른 자리로** 보냈나 ─────────────────
937
+ *
938
+ * 같은 모델이 여러 자리에 올라가 있으면 프리픽스 캐시는 **자리마다**
939
+ * 따로다. 다음 요청이 다른 자리로 가는 순간, 우리가 보낸 것이 한 글자도
940
+ * 안 달라져도 캐시는 0% 가 되고 그 턴은 전액 다시 나간다.
941
+ *
942
+ * 여태 이건 화면에 안 나왔다. 사람이 볼 수 있는 것은 며칠 뒤 청구서의
943
+ * 튄 줄 하나뿐이고, 그 줄만 보면 원인이 우리 쪽인지 게이트웨이 쪽인지
944
+ * 가를 방법이 없다. 서버가 말해 줄 때는 그 자리에서 말한다.
945
+ *
946
+ * 서버가 안 알려 주면 **아무 말도 안 한다.** 모르는 것을 「안 바뀌었다」
947
+ * 로 적지 않는다 (backend/adapter.js 의 간자리).
948
+ */
949
+ if (msg?.간자리) {
950
+ if (session.간자리 && session.간자리 !== msg.간자리) {
951
+ /*
952
+ * **한 대화에 한 번만** 말한다.
953
+ *
954
+ * 세션 고정 없이 돌아가며 나눠 주는 게이트웨이에서는 이게 걸음마다
955
+ * 바뀐다 — 그리고 그건 사실이다. 다만 사실이라고 걸음마다 찍으면
956
+ * 그 줄은 곧 아무도 안 읽고, 그러면 정작 한 번만 튀는 자리도 같이
957
+ * 안 읽힌다. 한 번 말하면 사람이 알아야 할 것은 다 말한 셈이다.
958
+ */
959
+ if (!session.자리바뀐것을말했나) {
960
+ session.자리바뀐것을말했나 = true;
961
+ yield { type: '자리바뀜', 몇번째: steps };
962
+ }
963
+ }
964
+ session.간자리 = msg.간자리;
965
+ }
966
+
806
967
  /*
807
968
  * 대답이 잘렸다면 한 번은 다시 부른다.
808
969
  *
@@ -821,9 +982,68 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
821
982
  * 그 경우는 아래 '살려 쓰기' 가 받는다.
822
983
  */
823
984
  const full = Math.max(cap, fullCap(room));
824
- const 낮춘생각 = level === 'off' || level === 'low' ? level : shiftLevel(level, -1);
985
+ /*
986
+ * `low` 에서도 한 칸 더 내려간다 — `off` 로.
987
+ *
988
+ * 여태 여기서 멈춰 있었다. 그런데 지금은 「생각이 실제로 자리를 먹었을
989
+ * 때만 다시 부른다」 이므로, low 인데 생각이 1,024토큰어치 자리를 먹은
990
+ * 판에서 다시 부르기를 거절하는 것은 앞뒤가 안 맞는다. 되찾을 자리가
991
+ * 있다고 재 놓고 안 되찾는 셈이다.
992
+ *
993
+ * 이 턴 하나만 끈다. 다음 걸음은 사람이 정한 눈금으로 되돌아간다.
994
+ */
995
+ const 낮춘생각 = level === 'off' ? level : shiftLevel(level, -1);
825
996
  let 마지막상한 = cap;
826
- if (wasCut(msg) && (full > cap || 낮춘생각 !== level)) {
997
+ /*
998
+ * ── 생각을 줄이는 것이 **자리를 실제로 내는가** ──────────────────────
999
+ *
1000
+ * 위 글이 「생각을 줄이면 예산이 실제로 남는다」 고 적어 뒀다. 맞는
1001
+ * 말인데, **생각을 안 한 턴에는 안 맞는다.** 상한을 못 올리는 자리에서
1002
+ * 이 조건은 `낮춘생각 !== level` 하나로 걸리므로, 생각이 0토큰이었어도
1003
+ * 눈금이 medium 이기만 하면 다시 부른다. 그러면 같은 상한으로 같은 답을
1004
+ * 다시 받고, 같은 자리에서 또 잘린다 — 바로 위 글이 「그건 그냥 낭비다」
1005
+ * 라고 적어 둔 그 일이다.
1006
+ *
1007
+ * 실제로 그 값을 봤다. 16,384 에서 잘린 답을 같은 16,384 로 다시 받아
1008
+ * 또 잘렸고, 그 한 번이 프리픽스 122K 와 출력 16K 를 통째로 다시 냈다.
1009
+ *
1010
+ * 그래서 **잰다.** 생각이 이번 답에서 자리를 얼마나 먹었는지 보고,
1011
+ * 눈금 한 칸으로 되찾을 만한 몫일 때만 다시 부른다. 서버가 추론 토큰을
1012
+ * 세어 주면 그 값을 쓰고, 안 세어 주면 흘러온 생각 글로 어림한다.
1013
+ * 둘 다 없으면 생각은 자리를 안 먹은 것이다 — 없는 것을 있다고 치고
1014
+ * 한 번 더 부르지 않는다.
1015
+ */
1016
+ /*
1017
+ * 생각이 얼마나 먹었나를 **세 갈래로** 잰다. 하나만 보면 못 잰다.
1018
+ *
1019
+ * 1. 서버가 세어 준 추론 토큰 — 있으면 제일 정확하다
1020
+ * 2. 흘러온 생각 글 — 세어 주지 않는 창구에서 쓴다
1021
+ * 3. **낸 토큰에서 보이는 글을 뺀 것** — 위 둘이 다 없을 때
1022
+ *
1023
+ * 3번이 중요하다. 게이트웨이가 생각 블록을 지워서 넘기면 1도 2도 0이
1024
+ * 되는데, 그때 「생각을 안 했다」 고 읽으면 정말로 생각에 예산을 다 쓴
1025
+ * 턴에서 다시 부르기를 그만둔다. 관측이 없는 것과 사건이 없는 것은
1026
+ * 다르다 — 그런데 **낸 토큰은 지워지지 않는다.** 답으로 나온 글이
1027
+ * 짧은데 낸 토큰이 상한에 닿았다면, 그 차이가 곧 안 보이는 생각이다.
1028
+ */
1029
+ const 낸것 = Number(msg?.usage?.out ?? 0) || 0;
1030
+ const 보인글 = estimateTokens(String(msg?.content ?? ''));
1031
+ const 생각몫 = Math.max(
1032
+ Number(msg?.usage?.reasoning ?? 0) || 0,
1033
+ estimateTokens(String(msg?.thinking ?? '')),
1034
+ 낸것 > 0 ? 낸것 - 보인글 : 0,
1035
+ );
1036
+ /*
1037
+ * 그 몫에서 **눈금 한 칸이 실제로 되찾아 줄 자리**가 쓸 만한가.
1038
+ *
1039
+ * 상한에 견준 비율로 재면 안 된다 — 같은 15% 가 16k 상한에서는 2,400
1040
+ * 토큰이고 2k 상한에서는 300토큰이라, 같은 잣대인 척하면서 다르게 잰다.
1041
+ * 되찾은 자리가 한 문단이라도 더 나올 만큼인지가 재야 할 것이고,
1042
+ * 그건 절대량이다.
1043
+ */
1044
+ const 생각이자리를먹었나 = 생각몫 * 되찾을몫 >= 쓸모있는자리;
1045
+ const 다시부를까 = wasCut(msg) && (full > cap || (낮춘생각 !== level && 생각이자리를먹었나));
1046
+ if (다시부를까) {
827
1047
  yield {
828
1048
  type: 'retry',
829
1049
  why: full > cap ? '대답이 상한에서 잘렸습니다' : '대답이 잘렸습니다 — 생각을 줄여 자리를 냅니다',
@@ -1711,6 +1931,18 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1711
1931
  if (call.name === 'Read' && result.content) session.noteRead(call.args.file_path, result.content);
1712
1932
  // 실제로 파일이 바뀐 것만 적는다. 턴 끝에 이 목록을 디스크와 견준다.
1713
1933
  if (result.changed) 손댄파일.add(result.changed);
1934
+ /*
1935
+ * 단계도 이걸 본다 (agent/단계.js).
1936
+ *
1937
+ * 「바꾸기 시작했다」 는 조사가 끝났다는 뜻이다. 손댄파일 로 재도
1938
+ * 되지만 그 집합은 하위 작업이 바꾼 것까지 담으므로, 이 턴에서
1939
+ * **내가** 바꿨나를 따로 센다. 하위가 바꿨다고 부모의 단계가
1940
+ * 넘어가면, 부모는 아직 조사 중인데 시킴말만 바뀐다.
1941
+ */
1942
+ if (result.changed || result.여럿?.some((f) => f.ok) || result.바뀐것들?.length) {
1943
+ 이번턴본것.바꿈 = true;
1944
+ }
1945
+ if (call.name === 'TodoWrite' && !result.error) 이번턴본것.할일 = true;
1714
1946
  // 한 번에 여러 개를 쓴 경우. changed 하나만 보면 나머지가 조용히 빠져서,
1715
1947
  // 턴 끝에 "만들어졌다" 고 확인해 주는 파일이 넷 중 하나만 나온다.
1716
1948
  for (const f of result.여럿 ?? []) if (f.ok && f.path) 손댄파일.add(f.path);
@@ -18,7 +18,10 @@
18
18
  // Ask 는 **모든 모드**에 있다(읽기 갈래에 둔 이유가 그것이다). 갈림길은
19
19
  // 어느 모드에서나 생기고, 물어볼 길이 없으면 모델은 글로 "알려주세요" 하고
20
20
  // 턴을 끝내 버린다 — 그러면 여태 조사한 것이 통째로 버려진다.
21
- const 읽기 = ['Read', 'Outline', 'Glob', 'Grep', 'Def', 'Refs', 'WebFetch', 'Skill', 'Recall', 'Ask'];
21
+ // 내보낸다 loop.js 동시에 돌릴 목록이 이것보다 짧아지면 검사가 잡게(test/parallel.test.js).
22
+ // 두 곳에 같은 목록을 따로 적어 둔 탓에 다섯 개가 몇 달 동안 줄을 서서 돌았다.
23
+ export const 읽기도구 = ['Read', 'Outline', 'Glob', 'Grep', 'Def', 'Refs', 'WebFetch', 'Skill', 'Recall', 'Ask'];
24
+ const 읽기 = 읽기도구;
22
25
  // 계획을 적는 도구. 파일을 안 건드리므로 읽기 전용 모드에서도 준다.
23
26
  //
24
27
  // Remember 도 여기 있다. 기억은 사용자의 소스를 안 건드리고 .deel/memory.md
@@ -0,0 +1,133 @@
1
+ /**
2
+ * 종합 모드에서 **단계가 일을 따라간다.**
3
+ *
4
+ * ── 무엇이 문제였나 ─────────────────────────────────────────────────────
5
+ *
6
+ * 종합 모드는 한마디를 보고 알맞은 작업 모드를 고른다(agent/route.js). 그런데
7
+ * 그 고르기가 **턴이 시작할 때 딱 한 번** 돌았다. 고른 뒤로는 그 턴이 끝날
8
+ * 때까지 안 바뀐다.
9
+ *
10
+ * 짧은 한마디에는 맞는 방식인데, 긴 일에서는 그게 곧 고장이다. 한 턴이 마흔
11
+ * 걸음을 도는 동안 일의 성격은 몇 번씩 바뀐다 —
12
+ *
13
+ * 「왜 안 되는지 찾아서 고쳐줘」 → 디버그로 시작한다. 열두 걸음쯤에 원인을
14
+ * 찾는다. 그때부터 남은 스물여덟 걸음은 **고치는 일**인데, 시킴말은 여전히
15
+ * 「원인을 찾아라 · 고치기 전에 재현부터 하라」 고 말하고 있다.
16
+ *
17
+ * 화면에도 `~디버그` 가 끝까지 떠 있다. 사람이 「알아서 계획·디버그·코드로
18
+ * 넘어가 달라」 고 한 것이 이 자리다.
19
+ *
20
+ * ── 무엇으로 옮기나 ─────────────────────────────────────────────────────
21
+ *
22
+ * 모델에게 「지금 무슨 단계냐」 고 한 번 더 묻지 않는다. 그러면 걸음마다 왕복이
23
+ * 하나 늘고, 그 판단은 route.js 가 낱말로 하던 것보다 나을 것도 없다.
24
+ *
25
+ * **이미 일어난 일**로 옮긴다. 짐작이 아니라 잰 것이다.
26
+ *
27
+ * 할 일을 적었다 (TodoWrite 에 남은 일이 있다) → 계획은 끝났다
28
+ * 파일을 실제로 바꿨다 → 만드는 중이다
29
+ *
30
+ * ── 앞으로만 간다 ───────────────────────────────────────────────────────
31
+ *
32
+ * 되돌아가지 않는다. 한 걸음 읽었다고 다시 조사 단계로 내려가면 단계가
33
+ * 걸음마다 오르내리고, 그때마다 시킴말이 바뀐다 — 그건 캐시를 걸음마다
34
+ * 깨뜨리면서 모델에게는 갈피를 못 잡게 하는 짓이다.
35
+ *
36
+ * ── 사람이 고른 모드는 안 건드린다 ──────────────────────────────────────
37
+ *
38
+ * `/plan` 을 직접 친 사람에게 계획 모드는 **약속**이다. 여기서 옮기는 것은
39
+ * 라우터가 **짐작한** 단계뿐이다. 그 둘을 가르는 것은 `종합인가` 하나다.
40
+ */
41
+
42
+ /*
43
+ * ── 단계가 넘어가는 표 ──────────────────────────────────────────────────
44
+ *
45
+ * 왼쪽에서 오른쪽으로만 간다. 여기 없는 모드는 안 옮긴다.
46
+ *
47
+ * auto → code 무슨 일인지 못 고른 채로 시작했는데 파일을 바꾸기 시작했다.
48
+ * 그러면 지금 하는 일은 만드는 일이다.
49
+ * debug → code 원인을 찾아 고치기 시작했다. 남은 걸음은 만드는 일이다.
50
+ * plan → code 계획을 다 적었다. 다만 이건 조건이 하나 더 붙는다 (아래).
51
+ *
52
+ * `architect` · `inspect` · `ask` 는 **안 옮긴다.**
53
+ *
54
+ * 셋 다 라우터가 「이 턴은 말로 답하는 턴」 이라고 본 자리다. 그리고 그렇게
55
+ * 보려면 사람의 말에 고치라는 말이 없어야 한다(route.js 의 손대라했나 가
56
+ * 그 셋을 후보에서 뺀다). 즉 **사람이 고쳐 달라고 안 했다.** 안 시킨 일을
57
+ * 스스로 시작하는 것은 단계가 나아가는 것이 아니라 시킨 범위를 넘는 것이다.
58
+ */
59
+ const 넘어갈곳 = {
60
+ auto: 'code',
61
+ debug: 'code',
62
+ plan: 'code',
63
+ };
64
+
65
+ /*
66
+ * `plan` 만 조건이 더 붙는다. **두 개다.**
67
+ *
68
+ * 계획 모드는 파일을 바꾸는 도구가 없다. 그래서 여기서 넘어가면 도구가
69
+ * 늘어나고, 늘어난 도구는 앞머리를 바꾸므로 캐시가 한 번 끊긴다. 값이 드는
70
+ * 일이라 아무 때나 하면 안 된다. 그보다 더 큰 것은, 이 자리가 **시킨 범위를
71
+ * 넘을 수 있는 유일한 자리**라는 점이다 — 다른 단계는 이미 파일을 바꿀 수
72
+ * 있는 자리에서 시작하지만 계획은 아니다.
73
+ *
74
+ * (1) 사람이 「묻지 말고 끝까지」 라고 적어 뒀어야 한다.
75
+ *
76
+ * 「계획해서 만들어줘」 는 이미 다른 길이 있다 — 계획을 내고 승인을
77
+ * 받아 이어서 하는 길이다(route.js 의 겹친요청). 그 길이 있는데 여기서
78
+ * 몰래 넘어가면 사람이 보자고 한 계획을 못 보고 파일부터 바뀐다.
79
+ * 그런데 겹침 길은 「묻지 말라」 고 적은 사람에게는 꺼져 있다. 그 사람
80
+ * 에게는 승인 창이 값이 아니라 손해이기 때문이다. 그러면 계획만 내고
81
+ * 멈추는 턴이 남는다 — 시킨 일의 절반이다. 그 나머지 절반이 이 자리다.
82
+ *
83
+ * (2) 그리고 사람이 **고쳐 달라고 했어야 한다** (route.js 의 손대라했나).
84
+ *
85
+ * (1)만으로는 구멍이 하나 남는다. 「계획만 세워줘. 알아서 진행해」 처럼
86
+ * **계획만 원하면서 묻지 말라고도 적은** 말이 있다. 그 사람은 승인 창이
87
+ * 싫다고 했을 뿐 파일을 고쳐 달라고 한 적이 없다. 그런데 (1)만 보면
88
+ * 할 일을 적는 순간 코드로 넘어가 파일이 바뀐다.
89
+ *
90
+ * 두 말은 뜻이 다르다 — 「묻지 마라」 는 **어떻게** 하라는 말이고,
91
+ * 「고쳐라」 는 **무엇을** 하라는 말이다. 범위를 정하는 것은 뒤엣것이다.
92
+ * 그래서 둘 다 있어야 넘어간다.
93
+ */
94
+ const 말에있어야하는것 = {
95
+ plan: (말) => 말.묻지말라 === true && 말.손대라 === true,
96
+ };
97
+
98
+ /** 이 단계에서 다음으로 넘어가려면 무엇이 일어나야 하나. */
99
+ const 신호 = {
100
+ // 파일을 바꾸기 전에는 아직 조사 중이다.
101
+ auto: (본것) => 본것?.바꿈 === true,
102
+ debug: (본것) => 본것?.바꿈 === true,
103
+ // 계획은 파일을 못 바꾼다. 그러니 「바꿨나」 로는 영영 안 넘어간다.
104
+ // 계획이 끝났다는 신호는 **적어 둔 할 일**이다.
105
+ plan: (본것) => 본것?.할일 === true,
106
+ };
107
+
108
+ /**
109
+ * 지금 걸음에서 어느 단계여야 하나.
110
+ *
111
+ * @param {object} 것
112
+ * @param {string} 것.지금 지금 단계 (작업 모드 id)
113
+ * @param {boolean} 것.종합인가 사람이 고른 모드가 아니라 종합 모드인가
114
+ * @param {object} 것.본것 { 할일: boolean, 바꿈: boolean } — 이번 턴에 일어난 일
115
+ * @param {boolean} 것.묻지말라 사람이 「묻지 말고 끝까지」 라고 적어 뒀나
116
+ * @param {boolean} 것.손대라 사람이 고쳐 달라고 했나 (route.js 의 손대라했나)
117
+ * @returns {string|null} 옮길 곳. 안 옮기면 null.
118
+ */
119
+ export function 다음단계({ 지금, 종합인가, 본것, 묻지말라 = false, 손대라 = false }) {
120
+ // 사람이 고른 모드는 약속이다. 짐작만 옮긴다.
121
+ if (!종합인가) return null;
122
+ const 갈곳 = 넘어갈곳[지금];
123
+ if (!갈곳 || 갈곳 === 지금) return null;
124
+ const 잠금 = 말에있어야하는것[지금];
125
+ if (잠금 && !잠금({ 묻지말라, 손대라 })) return null;
126
+ return 신호[지금]?.(본것) ? 갈곳 : null;
127
+ }
128
+
129
+ /** 넘어가면서 도구가 늘어나나 — 늘면 앞머리를 다시 쓰게 되므로 사람에게 말해야 한다. */
130
+ export function 도구가늘어나나(옛모드, 새모드, 도구가져오기) {
131
+ const 옛 = new Set(도구가져오기(옛모드));
132
+ return 도구가져오기(새모드).some((t) => !옛.has(t));
133
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 배운 것을 파일에서 읽어들일 때 **모양을 잰다.**
3
+ *
4
+ * ── 왜 따로 있나 ────────────────────────────────────────────────────────
5
+ *
6
+ * 신뢰도.js 는 「이 셈을 얼마나 믿을까」 를 정한다. 여기는 그 앞이다 —
7
+ * 「이게 애초에 우리가 적은 모양이 맞나」. 통계와 문지기는 다른 일이고,
8
+ * 섞어 두면 통계 문턱을 만질 때마다 문지기도 같이 흔들린다.
9
+ *
10
+ * ── 왜 문지기가 필요한가 ────────────────────────────────────────────────
11
+ *
12
+ * `배운것.json` 은 그냥 파일이다. 사람이 열어 고칠 수 있고, 다른 판이 다른
13
+ * 모양으로 적어 뒀을 수도 있고, 쓰다 만 채로 남았을 수도 있다.
14
+ *
15
+ * 그런데 그 안의 **열쇠는 시스템 프롬프트에 그대로 실린다** (evolve.js 의
16
+ * 요약 — 역따옴표로 감싸서 나간다). 그러니 이 파일을 읽는 자리가 곧 프롬프트로
17
+ * 들어가는 문이고, 문에는 자물쇠가 있어야 한다.
18
+ *
19
+ * 여기서 재는 것은 「참인가」 가 아니라 **「실려도 되는 꼴인가」** 다.
20
+ * 참인지는 신뢰도.js 가 정한다.
21
+ */
22
+
23
+ const 열쇠최대 = 40;
24
+
25
+ /*
26
+ * 무엇을 막을지가 아니라 **무엇만 받을지**를 적는다.
27
+ *
28
+ * 막을 것을 적으면 언젠가 빠뜨린다. 이 열쇠는 명령 이름 두 낱말이라 받을 것이
29
+ * 좁게 정해져 있다 — 글자·숫자·띄어쓰기와 경로에 쓰는 몇 개뿐이다.
30
+ *
31
+ * 역따옴표와 줄바꿈이 이 목록에 없는 것이 요점이다. 이 글자는 프롬프트에서
32
+ * 역따옴표로 감싸여 나가므로, 역따옴표 하나면 그 자리에서 틀이 깨지고
33
+ * 그다음 글이 명령 이름 행세를 한다.
34
+ */
35
+ const 받을꼴 = /^[A-Za-z0-9 _.:@+/-]+$/;
36
+
37
+ export function 성한열쇠인가(열쇠) {
38
+ const s = String(열쇠 ?? '');
39
+ if (!s || s.length > 열쇠최대) return false;
40
+ return 받을꼴.test(s);
41
+ }
42
+
43
+ export function 성한기록인가(r) {
44
+ if (!r || typeof r !== 'object') return false;
45
+ for (const 칸 of ['ok', 'no']) {
46
+ const v = Number(r[칸]);
47
+ if (!Number.isFinite(v) || v < 0) return false;
48
+ }
49
+ // 날짜가 없거나 못 읽으면 못 믿는다. 나이를 모르는 주장은 안 싣는다
50
+ // (신뢰도.js 의 며칠됐나 가 그때 무한대를 돌려준다).
51
+ return Number.isFinite(Date.parse(String(r.at ?? '')));
52
+ }
53
+
54
+ /**
55
+ * 표에서 모양이 안 맞는 것을 걷어낸 **사본**을 돌려준다.
56
+ *
57
+ * 고쳐서 쓰지 않는다. 모양이 안 맞는 것을 우리 짐작으로 고쳐 놓으면, 무엇이
58
+ * 어떻게 바뀌었는지가 또 아무 데도 안 남는다.
59
+ */
60
+ export function 거른표(표) {
61
+ const out = {};
62
+ for (const [k, v] of Object.entries(표 ?? {})) {
63
+ if (성한열쇠인가(k) && 성한기록인가(v)) out[k] = v;
64
+ }
65
+ return out;
66
+ }