genetics-mcp 2.23.0 → 2.24.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 (31) hide show
  1. package/bin/aarch64-apple-darwin/gcell +0 -0
  2. package/bin/aarch64-unknown-linux-musl/gcell +0 -0
  3. package/bin/manifest.json +5 -5
  4. package/bin/x86_64-apple-darwin/gcell +0 -0
  5. package/bin/x86_64-pc-windows-gnu/gcell.exe +0 -0
  6. package/bin/x86_64-unknown-linux-musl/gcell +0 -0
  7. package/genetics.mjs +2019 -227
  8. package/package.json +2 -2
  9. package/skills/gazette-report/SKILL.md +11 -10
  10. package/skills/gazette-report/scripts/tone.mjs +17 -5
  11. package/skills/gazette-report/scripts/tone.test.mjs +15 -2
  12. package/skills/gazette-report/template/skeleton.html +10 -10
  13. package/skills/genetics-define/SKILL.md +2 -0
  14. package/skills/genetics-report/SKILL.md +93 -87
  15. package/skills/genetics-report/assets/example/cases-briefing.json +1 -1
  16. package/skills/genetics-report/assets/example/cases.json +3 -3
  17. package/skills/genetics-report/assets/example/report-briefing-designs.json +26 -26
  18. package/skills/genetics-report/assets/example/report-briefing.json +3 -3
  19. package/skills/genetics-report/assets/example/report.json +11 -11
  20. package/skills/genetics-report/assets/mcp-call.mjs +109 -0
  21. package/skills/genetics-report/assets/patch.py +196 -0
  22. package/skills/genetics-report/assets/render.py +172 -0
  23. package/skills/genetics-report/assets/xlsx-plate.py +203 -0
  24. package/skills/genetics-report/references/content-pipeline.md +26 -26
  25. package/skills/genetics-report/references/edition-review.workflow.js +70 -15
  26. package/skills/genetics-report/references/history.md +76 -2
  27. package/skills/genetics-report/references/lim-portal-format.md +100 -45
  28. package/skills/genetics-report/references/linear-read-test.workflow.js +50 -14
  29. package/skills/genetics-report/references/process.md +47 -43
  30. package/skills/genetics-report/references/reader-review.workflow.js +105 -28
  31. package/skills/genetics-report/references/visual-first.md +29 -12
package/genetics.mjs CHANGED
@@ -182,11 +182,14 @@ const lockSleepCell = new Int32Array(new SharedArrayBuffer(4));
182
182
 
183
183
  function isoStamp() { return new Date().toISOString().replace(/[:.]/g, '-'); }
184
184
 
185
- /** 잠금 파일이 죽은 자의 것인가 — 같은 호스트의 죽은 pid 이거나 30초 넘게 묵었다. 그때 그 내용을, 아니면 null. */
186
- function lockStaleText(lock) {
185
+ /**
186
+ * 잠금 파일이 죽은 자의 것인가 — 같은 호스트의 죽은 pid 이거나 `staleMs`(기본 30초) 넘게 묵었다. 그때 그 내용을, 아니면 null.
187
+ * `staleMs` 는 입구 임대(`LEASE_QUIET_MS`)가 쓴다 — 긴 PUT 을 쥔 산 주인의 임대를 30초 만에 걷지 않게(§3.2 · C7).
188
+ */
189
+ function lockStaleText(lock, staleMs = LOCK_STALE_MS) {
187
190
  let st, text;
188
191
  try { st = fs.statSync(lock); text = fs.readFileSync(lock, 'utf8'); } catch { return null; }
189
- if (Date.now() - st.mtimeMs > LOCK_STALE_MS) return text;
192
+ if (Date.now() - st.mtimeMs > staleMs) return text;
190
193
  const [pid, host] = text.split('\n');
191
194
  if (host === os.hostname() && /^[1-9][0-9]*$/.test(pid || '')) {
192
195
  try { process.kill(Number(pid), 0); } catch (e) { if (e?.code === 'ESRCH') return text; }
@@ -225,7 +228,8 @@ function sweepLeftovers(file) {
225
228
  const now = Date.now();
226
229
  try {
227
230
  for (const n of fs.readdirSync(dir)) {
228
- if (!n.startsWith(`${base}.tmp-`) && !n.startsWith(`${base}.lock.stale-`)) continue;
231
+ // `<file>.lock.stale-` 은 기본 잠금 자리, `<file>.stale-` 은 잠금 파일을 직접 준 자리(입구 임대 `push/<uuid>.lease`).
232
+ if (!n.startsWith(`${base}.tmp-`) && !n.startsWith(`${base}.lock.stale-`) && !n.startsWith(`${base}.stale-`)) continue;
229
233
  const f = path.join(dir, n);
230
234
  try { if (now - fs.statSync(f).mtimeMs > LOCK_STALE_MS) fs.rmSync(f, { force: true }); } catch { /* 남이 지웠다 */ }
231
235
  }
@@ -233,12 +237,18 @@ function sweepLeftovers(file) {
233
237
  }
234
238
 
235
239
  /**
236
- * @param {number} [deadline] epoch ms — 이 시각을 넘기면 더 안 기다린다(0 이면 상한 없음, 호환 N9).
240
+ * @param {number} [deadline] epoch ms — 이 시각을 넘기면 더 안 기다린다(0 이면 LOCK_TRIES 만큼, 호환 N9).
237
241
  * 기다림은 `Atomics.wait` 이라 **이 고리가 곧 MCP 의 stdio 고리다** — 한 번의 쓰기가 몇 초씩 잡으면 심박도 도구 응답도 멎는다.
238
- */
239
- function fileLock(file, deadline = 0) {
240
- const lock = `${file}.lock`;
241
- const token = `${process.pid}\n${os.hostname()}\n${Date.now()}`;
242
+ * `Date.now()` 를 주면 **한 번만** 잡아 본다(묵은 잠금을 걷는 한 번은 더 탄다) — 입구 임대의 비차단 모양(§3.2 · C7).
243
+ * @param {{stale?: number, lock?: string}} [o] `stale` 묵음 한도(기본 30초) · `lock` 잠금 파일 자리(기본 `<file>.lock`)
244
+ * — 입구 임대는 `push/<uuid>.lease` 를 그대로 잠금 파일로 쓴다(새 원시를 안 만든다, race S2).
245
+ */
246
+ function fileLock(file, deadline = 0, o = {}) {
247
+ const lock = o.lock || `${file}.lock`;
248
+ const staleMs = Number(o.stale) > 0 ? Number(o.stale) : LOCK_STALE_MS;
249
+ // 토큰 끝의 난수 — 같은 프로세스·같은 ms 에 잡는 둘(사람 push 와 자동 바퀴, C7)이 서로의 잠금을 제 것으로 읽지 않게.
250
+ // 앞 세 줄(pid·호스트·ms)은 그대로라 `lockStaleText` 와 옛 판이 읽는 모양이 안 바뀐다.
251
+ const token = `${process.pid}\n${os.hostname()}\n${Date.now()}\n${crypto.randomBytes(4).toString('hex')}`;
242
252
  let broke = false;
243
253
  // deadline 0 = 첫 시도(jsonFileUpdate) — §5.2 대로 **상한 없이** 기다린다(묵은 잠금은 30초에 걷힌다). 2초(LOCK_TRIES)
244
254
  // 에서 포기하면 그 쓰기는 `nolock` 으로 되풀이 없이 사라진다 — 창 열둘이 한 파일을 두드리는 관문에서 599/600(2026-09-16 실측).
@@ -252,7 +262,7 @@ function fileLock(file, deadline = 0) {
252
262
  if (e?.code !== 'EEXIST') return null;
253
263
  }
254
264
  if (!broke) {
255
- const stale = lockStaleText(lock);
265
+ const stale = lockStaleText(lock, staleMs);
256
266
  if (stale !== null) {
257
267
  broke = true; // 한 번만 걷고 다시 잡는다
258
268
  lockBreakStale(lock, stale);
@@ -799,6 +809,61 @@ function encodeShot(buf) {
799
809
  + '여러 장을 한 판에 몰아 넣으면 어차피 문서에서 글자가 안 읽힙니다.');
800
810
  }
801
811
 
812
+ /**
813
+ * 영상 썸네일 판(스프라이트) — 열람 셸의 시크바가 끌 때 「그 자리 그림」을 보이려면 프레임이 있어야 하는데,
814
+ * 문서 CSP(default-src 'none')·sandbox 아래에서는 셸이 매체를 직접 열어 프레임을 뽑을 수 없다(2026-09-23).
815
+ * 그래서 **발행 때 이 기계에서** ffmpeg 로 `step` 초마다 한 장씩 뽑아 한 판(10열 × 140px)으로 붙이고 cwebp 로
816
+ * 굳혀(encodeShot — 장당 상한 ASSET_MAX_ONE 그대로) 자산의 `thumbs` 칸에 data: 로 싣는다. 렌더러가
817
+ * `<video data-thumbs…>` 로 내고 셸(assets/reader-video.js)이 **읽기만** 해서 자기 층에 그린다 — 발행물 바이트의
818
+ * 일부이지 실행 코드가 아니다(문서 CSP img-src data: 안). ffmpeg/ffprobe 가 없으면 **판 없이 간다**(힌트 한 줄) —
819
+ * 영상 자체는 그대로 올라가고 시크바는 그림 없이 선다. 장당 상한을 넘으면 간격을 두 배로 한 번 더 시도한다.
820
+ */
821
+ const THUMB_W = 140, THUMB_COLS = 10;
822
+ function videoThumbs(fpath, stepSec = 1) {
823
+ const probe = spawnSync('ffprobe', ['-v', 'error', '-select_streams', 'v:0',
824
+ '-show_entries', 'stream=width,height:format=duration', '-of', 'json', fpath], { encoding: 'utf8' });
825
+ if (probe.error && probe.error.code === 'ENOENT') {
826
+ ASSET_HINTS.push(`영상 「${path.basename(fpath)}」: ffprobe/ffmpeg 가 없어 썸네일 판 없이 올립니다(brew install ffmpeg).`);
827
+ return null;
828
+ }
829
+ let meta = null;
830
+ try { meta = JSON.parse(String(probe.stdout || '')); } catch (e) { meta = null; }
831
+ const dur = parseFloat(meta && meta.format && meta.format.duration);
832
+ const sw = Number(meta && meta.streams && meta.streams[0] && meta.streams[0].width);
833
+ const sh = Number(meta && meta.streams && meta.streams[0] && meta.streams[0].height);
834
+ if (!(dur > 0) || !(sw > 0) || !(sh > 0)) return null;
835
+ // 판의 세로가 긴 변 상한(ASSET_LONG_EDGE)을 넘으면 encodeShot 이 줄여 버려 칸 좌표가 어긋난다 — 가로는 10열 × 140 = 1400
836
+ // 으로 딱 상한이고, 세로는 **간격을 늘려** 줄 수를 맞춘다(한 칸 높이 ≈ 140 × 세로/가로, 짝수). 실제 치수는 뽑은 뒤 다시 잰다.
837
+ const frameH = Math.max(2, 2 * Math.round(THUMB_W * sh / sw / 2));
838
+ const fits = (step) => Math.ceil(Math.max(1, Math.ceil(dur / step)) / THUMB_COLS) * frameH <= ASSET_LONG_EDGE;
839
+ for (const mul of [1, 2, 4, 8, 16, 32]) {
840
+ const step = stepSec * mul;
841
+ if (!fits(step)) continue;
842
+ const n = Math.max(1, Math.ceil(dur / step));
843
+ const rows = Math.ceil(n / THUMB_COLS);
844
+ const r = spawnSync('ffmpeg', ['-v', 'error', '-i', fpath,
845
+ '-vf', `fps=1/${step},scale=${THUMB_W}:-2,tile=${THUMB_COLS}x${rows}`,
846
+ '-frames:v', '1', '-f', 'image2pipe', '-vcodec', 'png', '-'], { maxBuffer: IMAGE_MAX_DOWNLOAD });
847
+ if (r.error && r.error.code === 'ENOENT') {
848
+ ASSET_HINTS.push(`영상 「${path.basename(fpath)}」: ffmpeg 가 없어 썸네일 판 없이 올립니다(brew install ffmpeg).`);
849
+ return null;
850
+ }
851
+ if (r.status !== 0 || !r.stdout || !r.stdout.length) {
852
+ ASSET_HINTS.push(`영상 「${path.basename(fpath)}」: 썸네일 판을 못 만들어 없이 올립니다 — ${String(r.stderr).slice(0, 120)}`);
853
+ return null;
854
+ }
855
+ const d = imageDims(r.stdout);
856
+ if (!d || d.w > ASSET_LONG_EDGE || d.h > ASSET_LONG_EDGE || d.h % rows !== 0) continue; // 추정이 빗나갔다 — 간격을 늘려 다시
857
+ let enc;
858
+ try { enc = encodeShot(r.stdout); } catch (e) { continue; } // 장당 상한 초과 — 간격을 늘려 한 번 더
859
+ const h = d.h / rows;
860
+ ASSET_HINTS.push(`영상 「${path.basename(fpath)}」 썸네일 판 ${n}장(${step}초 간격) ${(enc.length / 1024).toFixed(0)}KB.`);
861
+ return { data: 'data:image/webp;base64,' + enc.toString('base64'), n, cols: THUMB_COLS, w: THUMB_W, h, step };
862
+ }
863
+ ASSET_HINTS.push(`영상 「${path.basename(fpath)}」: 썸네일 판이 장당 상한을 넘어 없이 올립니다(영상이 깁니다).`);
864
+ return null;
865
+ }
866
+
802
867
  function cwebpMissing() {
803
868
  return new Error('cwebp 가 없어 자산을 계약 규격(WebP)으로 굳힐 수 없습니다.\n'
804
869
  + `libwebp 를 설치하세요(${installHint('webp')}). `
@@ -914,14 +979,27 @@ async function materializeAssets(data, tenant) {
914
979
  * (되읽은 판을 고쳐 되돌려 보내는 왕복에서 같은 영상을 두 번 올리지 않는다).
915
980
  */
916
981
  if (String(a?.role ?? '') === 'video' || String(a?.provider ?? '') === 'r2') {
917
- const { data: _vd, ...rest } = meta;
918
- if (Number(rest.ref) > 0) { out.push({ ...rest, role: 'video', provider: 'r2' }); continue; }
982
+ const { data: _vd, thumbs: given, ...rest } = meta;
983
+ /* 썸네일 판 — 실물(data:image/…)이면 그대로, `null` 이면 뺀 것(서버 report_carry_assets 와 같은 뜻).
984
+ * 되읽기 표식('(생략 — NB)')은 실물이 아니다 — 판이 있는 것으로 치면 표식이 그대로 발행돼 거절된다. */
985
+ const optOut = given === null;
986
+ const real = !!(given && typeof given.data === 'string' && given.data.startsWith('data:image/'));
987
+ const slot = (t) => (optOut ? { thumbs: null } : (t && typeof t === 'object' ? { thumbs: t } : {}));
988
+ if (Number(rest.ref) > 0) {
989
+ // 이미 올린 영상 — 판이 실물이 아니면 file 에서 새로 굽는다(영상은 다시 안 올린다). file 도 없으면 받은
990
+ // 표식을 그대로 보낸다 — 서버가 같은 영상(ref)의 저장된 판을 잇는다.
991
+ const t = (optOut || real) ? given : ((fpath ? videoThumbs(String(fpath)) : null) || given);
992
+ out.push({ ...rest, role: 'video', provider: 'r2', ...slot(t) });
993
+ continue;
994
+ }
919
995
  if (!fpath) {
920
996
  throw new Error(`영상 「${rest.id ?? '(id 없음)'}」 에 file(로컬 mp4 경로)도 ref 도 없습니다.\n`
921
997
  + 'file 에 절대경로를 주면 올리고 번호를 받아 싣습니다.');
922
998
  }
923
999
  const ref = await uploadVideo(tenant, readLocalVideo(String(fpath)), String(fpath));
924
- out.push({ ...rest, role: 'video', provider: 'r2', ref, mime: 'video/mp4' });
1000
+ // 새로 올린 영상 — 표식이 가리키던 옛 판은 다른 장면이라 버리고 이 파일에서 굽는다.
1001
+ const t = (optOut || real) ? given : videoThumbs(String(fpath));
1002
+ out.push({ ...rest, role: 'video', provider: 'r2', ref, mime: 'video/mp4', ...slot(t) });
925
1003
  continue;
926
1004
  }
927
1005
  if (!a?.source && !fpath) {
@@ -1020,9 +1098,31 @@ function warnOnce(key, msg) {
1020
1098
  try { process.stderr.write(`genetics: ${msg}\n`); } catch { /* 그것마저 막히면 넘어간다 */ }
1021
1099
  }
1022
1100
 
1101
+ /**
1102
+ * 【선언】 요청 하나의 시간 상한 — **바탕 120초**(DESIGN-session-delta-sync §2.10 · race S1 · C7). 시험은 120초를 못 기다려
1103
+ * `GENETICS_API_TIMEOUT_MS` 로 갈아 끼운다 — 하한 1초(`GENETICS_OBSERVE_MS` 와 같은 규율: 실수로 0 이 들어가 모든 요청이
1104
+ * 즉시 끊기지 않게).
1105
+ */
1106
+ const API_TIMEOUT_MS = Math.max(1000, Number(cfg.GENETICS_API_TIMEOUT_MS) || 120_000);
1107
+ /**
1108
+ * 【선언】 업링크의 **바닥 속도** 32KB/s(=256kbit/s). 상한을 벽시계 하나로 두면 **큰 봉투가 영영 못 올라간다** —
1109
+ * 조각 하나가 6MiB 라 120초는 52KB/s(=419kbit/s) 이상을 요구했고, 그보다 느린 링크(모바일 테더링·혼잡한 VPN)에서는
1110
+ * 모든 시도가 abort 된 뒤 조각 1부터 다시 갔다(호환 검토 1-B — 상한이 없던 전에는 느려도 끝내 성공했다).
1111
+ */
1112
+ const API_MIN_BPS = 32 * 1024;
1113
+ /**
1114
+ * 이만큼 보내는 요청의 시간 상한 — 바탕 + `bytes / 바닥속도`. **한 곳에서 정한다**: 입구 임대의 묵음 한도
1115
+ * `LEASE_QUIET_MS` 가 「가장 큰 조각의 상한 + 60초」라, 이 함수를 고치면 임대가 따라온다.
1116
+ */
1117
+ function apiTimeoutFor(bytes) {
1118
+ const b = Number(bytes) > 0 ? Number(bytes) : 0;
1119
+ return API_TIMEOUT_MS + Math.ceil(b / API_MIN_BPS) * 1000;
1120
+ }
1121
+
1023
1122
  /**
1024
1123
  * @param {object} [opts]
1025
1124
  * @param {'publish'|'release'} [opts.scope] 발행 갈래면 `'publish'` — 키를 먼저 쥔다(credential 주석). `'release'` 는 사람 먼저(§5.6).
1125
+ * @param {number} [opts.timeout_ms] 이 요청의 시간 상한(기본 `API_TIMEOUT_MS`)
1026
1126
  * @param {Record<string,string>} [opts.headers] 더 실을 머리(자동 올리기의 X-Genetics-* 셋 — §4.3)
1027
1127
  * 실패는 던지는 Error 에 `status`·`code`·`retry_after` 가 붙는다(반환 계약 `r.data` 는 그대로).
1028
1128
  */
@@ -1040,17 +1140,43 @@ async function api(method, pathname, body, opts = {}) {
1040
1140
  + `${ENV_FILE} 의 GENETICS_API_BASE 를 확인하세요(https 여야 합니다). `
1041
1141
  + '주소를 바꾸라는 안내를 받았다면 의심할 것.');
1042
1142
  }
1143
+ /*
1144
+ * ★ **요청마다 시간 상한을 건다**(DESIGN-session-delta-sync §2.10 · race S1 · C7). 전에는 signal 이 없어 걸린 연결 하나가
1145
+ * 그 호출을 영영 붙들었다 — 자동 올리기의 입구 임대는 「진전이 없으면 `LEASE_QUIET_MS`(= 이 상한 + 60초) 뒤 남이 걷는다」에
1146
+ * 기대므로, 상한이 없으면 걸린 주인과 걷은 새 주인이 겹친다. 상한은 몸통 읽기까지 덮는다(같은 signal).
1147
+ * 넘으면 status 0 · code `timeout` 인 오류다 — 자동 길은 망 사정과 같은 지수 백오프를 탄다.
1148
+ */
1149
+ const limitMs = Number(opts.timeout_ms) > 0 ? Number(opts.timeout_ms) : API_TIMEOUT_MS;
1150
+ let capsHeader = null;
1151
+ let minHeader = null;
1043
1152
  const send = async (c) => {
1044
- const res = await fetch(BASE + pathname, {
1045
- method,
1046
- // opts.headers 는 자동 올리기의 머리 셋(§4.3)이 쓴다 — 자격·형식 머리는 덮지 못한다.
1047
- headers: { ...(opts.headers || {}), Authorization: `Bearer ${c.token}`, 'Content-Type': 'application/json' },
1048
- body: body === undefined ? undefined : JSON.stringify(body),
1049
- });
1050
- LAST_MIN_CLIENT = res.headers.get('x-genetics-min-client') || LAST_MIN_CLIENT; // §5.8 — 반환 계약은 그대로
1051
- const text = await res.text();
1153
+ const signal = AbortSignal.timeout(limitMs);
1154
+ const timedOut = (e) => (e?.name === 'TimeoutError' || (e?.name === 'AbortError' && signal.aborted)
1155
+ ? Object.assign(new Error(`서버가 ${Math.round(limitMs / 1000)}초 안에 답하지 않았습니다(${method} ${pathname}).`), { status: 0, code: 'timeout' })
1156
+ : e);
1157
+ let res, text;
1158
+ try {
1159
+ res = await fetch(BASE + pathname, {
1160
+ method,
1161
+ // opts.headers 는 자동 올리기의 머리 셋(§4.3)이 쓴다 — 자격·형식 머리는 덮지 못한다.
1162
+ headers: { ...(opts.headers || {}), Authorization: `Bearer ${c.token}`, 'Content-Type': 'application/json' },
1163
+ body: body === undefined ? undefined : JSON.stringify(body),
1164
+ signal,
1165
+ });
1166
+ LAST_MIN_CLIENT = res.headers.get('x-genetics-min-client') || LAST_MIN_CLIENT; // §5.8 — 반환 계약은 그대로
1167
+ capsHeader = res.headers.get('x-genetics-session-caps'); // §6.1 — 덧붙임 능력 광고(C8)
1168
+ minHeader = res.headers.get('x-genetics-min-client'); // 「우리 PHP 가 답했다」의 표(아래 capsNote)
1169
+ text = await res.text();
1170
+ } catch (e) { throw timedOut(e); }
1052
1171
  let data;
1053
- try { data = JSON.parse(text); } catch { throw Object.assign(new Error(`응답을 해석하지 못했습니다(HTTP ${res.status}): ${text.slice(0, 300)}`), { status: res.status }); }
1172
+ // `not_json` — 봉투가 `post_max_size` 를 넘으면 PHP 가 본문을 버리고 200·`text/html` 을 낸다. 덧붙임은 그 표로 조각을 더 쪼갠다(C8).
1173
+ try { data = JSON.parse(text); } catch { throw Object.assign(new Error(`응답을 해석하지 못했습니다(HTTP ${res.status}): ${text.slice(0, 300)}`), { status: res.status, not_json: true }); }
1174
+ /*
1175
+ * ★ 능력 광고는 **JSON 으로 읽힌 응답에서만** 잰다(§6.1 · C8). 봉투가 `post_max_size` 를 넘으면 PHP 가 본문을 버리고
1176
+ * `text/html` + 경고 덩어리를 200 으로 내는데 머리가 하나도 안 실린다(dev 실측) — 그 갈래는 위 `JSON.parse` 에서
1177
+ * 먼저 죽으므로 여기 안 온다. 「광고가 없으니 옛 서버」를 그 응답으로 적으면 조용히 덧붙임이 꺼진다.
1178
+ */
1179
+ capsNote(pathname, res.status, capsHeader, data, !!minHeader);
1054
1180
  return { ok: res.ok && data.ok !== false, status: res.status, data };
1055
1181
  };
1056
1182
 
@@ -1084,9 +1210,18 @@ async function api(method, pathname, body, opts = {}) {
1084
1210
  // 자동 올리기의 백오프(§5.5 7)가 상태·code·retry_after 로 갈린다 — 메시지는 그대로, 칸만 붙인다.
1085
1211
  // `auto_upload_at` 은 409 `too_old`(관문 11)가 **서버의 동의 시각**을 알려 주는 칸이다 — 그것이 곧 클라이언트가
1086
1212
  // 못 받은 하한이라, 받아 두면 같은 거절을 24시간마다 되풀이하지 않는다(개인정보 N3).
1213
+ // `coords` — 409 `behind`·`base_moved` 가 싣는 서버 좌표(C3·C4). 따라잡기(C8)가 읽는다.
1214
+ // `why` — 덧붙임의 409 `no_base`·`rebake`·`base_lost` 가 가르는 칸(C4). §5.1 의 다음 동작이 이것으로 갈린다.
1215
+ // `caps` — 그 **거절 응답**이 실은 능력 광고. 덧붙임의 400·404 가 「서버가 모른다」인지 「이 요청을 거절했다」인지 가른다(C8).
1216
+ const coordKeys = ['rev', 'bytes', 'raw_off', 'raw_key_id', 'raw_win_mac'].filter((k) => k in (r.data || {}));
1217
+ // `comment` — 완료 보고의 400 이 **이미 남긴 글**을 함께 싣는다(근거가 없어 검수로 못 간 경우). 버리면 글 번호를 잃는다(2026-09-26 검토).
1087
1218
  throw Object.assign(new Error(r.data.error || `HTTP ${r.status}`),
1088
1219
  { status: r.status, code: String(r.data.code || ''), retry_after: Number(r.data.retry_after) || 0,
1089
- auto_upload_at: typeof r.data.auto_upload_at === 'string' ? r.data.auto_upload_at : '' });
1220
+ caps: String(capsHeader || ''),
1221
+ auto_upload_at: typeof r.data.auto_upload_at === 'string' ? r.data.auto_upload_at : '',
1222
+ why: typeof r.data.why === 'string' ? r.data.why : '',
1223
+ ...(r.data.comment && typeof r.data.comment === 'object' ? { comment: r.data.comment } : {}),
1224
+ ...(coordKeys.length ? { coords: Object.fromEntries(coordKeys.map((k) => [k, r.data[k]])) } : {}) });
1090
1225
  }
1091
1226
  return r.data;
1092
1227
  }
@@ -1332,7 +1467,7 @@ const REPORT_DATA = {
1332
1467
  + '· `marg`: {why, key, advice} — **좌측 여백 주석 단. 이것이 본체다.** '
1333
1468
  + '없으면 두 단 본문이 한 단으로 서고, 문서는 「문서처럼 꾸민 웹페이지」가 된다. '
1334
1469
  + 'why 는 **독자가 실제로 던질 질문**(「무엇을 조치해야 하는가?」), '
1335
- + 'key 는 이 절에서 하나만 가져간다면 그것, advice 는 어디를 참조하라는 것. '
1470
+ + 'key 는 이 절에서 하나만 가져간다면 그것, advice 는 더 볼 곳 — 명사구(「03절 대조표」)나 사물이 주어인 단문(「판정 기준은 부록 1에 있음」), 독자에게 시키는 꼴(「~할 것」「~ 참조」)은 아님. '
1336
1471
  + '채우려고 넣으면 안 넣느니만 못하다.\n'
1337
1472
  + '· `lede`: 절 간지의 도입문 — 다루는 범위와 **다루지 않는 범위**.\n'
1338
1473
  + '· `sub`: 차례 행에 붙는 한 줄 설명. `short`: 측면 색인 레일용 짧은 이름.\n'
@@ -1429,7 +1564,7 @@ const REPORT_DATA = {
1429
1564
  type: 'string',
1430
1565
  description: 'role: figure 의 **그림 캡션.** 「그림 4-1」 번호 뒤에 붙는다(번호는 자리가 '
1431
1566
  + '매기므로 적지 않는다). 무엇을 보이는 화면인지 한 문장으로 — 본문이 이미 말한 것을 '
1432
- + '되풀이하지 말고 **이 화면에서 무엇을 보라는 것인지**를 적는다.',
1567
+ + '되풀이하지 말고 **이 화면에서 독자가 볼 자리**를 명사형으로 적는다(「8.0 행의 두 날짜와 8.4 행의 종료일」).',
1433
1568
  },
1434
1569
  source_text: {
1435
1570
  type: 'string',
@@ -1474,6 +1609,13 @@ const REPORT_DATA = {
1474
1609
  + '참조로 나머지를 이어야 한 세트로 보인다(참조 없이 내면 장마다 인물이 달라진다). '
1475
1610
  + 'hero 자신에게는 뜻이 없다.',
1476
1611
  },
1612
+ // 영상 썸네일 판(d9dde6f7) — 렌더러가 읽는 칸(gazette.py `<video data-thumbs…>`)이라 계약에 적는다(tools/contract-drift.py).
1613
+ thumbs: {
1614
+ type: ['object', 'null'],
1615
+ description: '`provider: r2`(영상) 자산의 **시크바 썸네일 판** — 열람 화면에서 시크바를 끌 때 그 자리 그림을 보인다. '
1616
+ + '**보통은 적지 않는다** — `file` 에 로컬 mp4 를 주면 이 도구가 발행 때 ffmpeg 로 굽는다(ffmpeg 가 없으면 판 없이 간다). '
1617
+ + '`null` 이면 판을 뺀다. 되읽은 판을 고쳐 되돌려 보낼 때는 받은 값을 그대로 두면 서버가 저장된 판을 잇는다.',
1618
+ },
1477
1619
  alt: { type: 'string', description: '설명적 대체 텍스트 — 접근성 계약. 그림과 함께 다닌다' },
1478
1620
  origin: { type: 'string', description: '생성 job 식별자·.mmd 원본 경로·제공자 이름' },
1479
1621
  caption: { type: 'string', description: '도판 캡션. 그림 번호는 자리가 매기므로 적지 않는다' },
@@ -1541,8 +1683,8 @@ const REPORT_DATA = {
1541
1683
  symptom: { type: 'string' },
1542
1684
  cause: { type: 'string' },
1543
1685
  fix: { type: 'string' },
1544
- how_to_check: { type: 'string' },
1545
- remaining: { type: 'string', description: '고객 확인이 필요한 사항(선택)' },
1686
+ how_to_check: { type: 'string', description: '고객이 화면에서 볼 수 있는 결과 — 어디를 열면 무엇이 보이는지(주어는 화면·값, 독자에게 시키는 문장 아님)' },
1687
+ remaining: { type: 'string', description: '아직 닫히지 않은 것 — 미확인·미결인 사실과 운영에 아직 안 나간 우리 작업. 독자에게 부탁할 것은 open_items 로(선택)' },
1546
1688
  source_url: { type: 'string', description: 'http(s) 제보 원본 링크(선택)' },
1547
1689
  quotes: {
1548
1690
  type: 'array',
@@ -1593,6 +1735,16 @@ const REPORT_DATA = {
1593
1735
  * 심볼릭 링크라 리터럴 경로로 만들면 엉뚱한 디렉터리에 놓인다(그렇게 세 번 틀렸다)
1594
1736
  */
1595
1737
 
1738
+ /**
1739
+ * 클라이언트 검열 **규칙판**(DESIGN-session-delta-sync §2.8 · C6). 사람이 올리는 상수다 — 검열 **출력 바이트**를 바꾸는
1740
+ * 원천(`mcp/scrub-rev.lock` 의 목록: SECRET_SHAPES·MASK·DROP_TYPES·KEEP_ATTACHMENT_PREFIX·isKeptAttachment·
1741
+ * maskHomeInPick·maskSecrets·maskDeep·relHome·scrubTranscript)을 고치면 이 판을 올리고 lock 에 새 줄을 덧붙인다.
1742
+ * smoke 의 「규칙판 lock」 절이 원문 지문(주석·공백 걷은 토큰)과 판을 대조한다 — 원천이 바뀌었는데 판이 그대로면 빨갛다.
1743
+ * ⚠ 서버 `AGENT_SCRUB_REV`(r…)와는 **다른 이름공간**이다(m…). 전체 PUT 은 이 값을 좌표의 규칙판으로 싣고, 서버는 행에
1744
+ * 자기 판을 적어 응답 `scrub_rev` 로 돌려준다 — 대응표는 둘 다 적는다(scrub_rev · srv_scrub_rev).
1745
+ */
1746
+ const SCRUB_REV = 'm1';
1747
+
1596
1748
  /** 비밀 **모양** — `www/portal/lib/agent_session.php` 의 `agent_secret_shapes()` 와 **같아야 한다.**
1597
1749
  * PHP 와 JS 는 코드를 공유할 수 없으므로 `mcp/smoke.mjs` 가 두 목록의 이름을 대조한다. */
1598
1750
  const SECRET_SHAPES = [
@@ -1712,10 +1864,22 @@ function cwdMode(counts) {
1712
1864
  return best;
1713
1865
  }
1714
1866
 
1715
- function scrubTranscript(raw) {
1867
+ /**
1868
+ * @param {string} raw 원본 전사의 **줄 경계에서 끊긴** 텍스트(부르는 쪽이 마지막 `\n` 까지 자른다 — §2.1)
1869
+ * @param {{delta?: boolean, hist?: Object<string, number>}} [opts]
1870
+ * `delta` — 변화분 모드(§2.2): 남는 줄이 0이어도 예외 대신 `{jsonl:'', lines:0, …}`. 전체 모드의 「빈 트랜스크립트」 예외는 그대로.
1871
+ * `hist` — 앞 구간까지 센 `cwd_hist`(raw cwd → 셈). 이어 세면 `cwd`·`cwd_raw`·`cwd_hist` 가 전체를 한 번에 센 것과 같다.
1872
+ * 출력 줄은 **그 줄만의 함수**다 — 그래서 [0,a)·[a,b)·… 로 잘라 검열한 jsonl 을 이으면 전체를 한 번에 검열한 바이트와 같다.
1873
+ * `cwd_hist` 는 **자르지 않은 전부**다(.genetics-private 판정이 열쇠 전부를 본다 — 예전 상위 64 자르기는 65위 아래를 흘렸다).
1874
+ */
1875
+ function scrubTranscript(raw, opts = {}) {
1876
+ const delta = opts?.delta === true;
1716
1877
  const out = [];
1717
- const cwds = new Map();
1718
- const cwdsRaw = new Map(); // 폴더 identity 는 realpath 뒤에 잰다(§3.2) — 이 기계 안에서만 쓰고 보내지 않는다
1878
+ // 폴더 identity 는 realpath 뒤에 잰다(§3.2) — 이 기계 안에서만 쓰고 보내지 않는다
1879
+ // ⚠ 동수 판정은 「처음 본 차례」를 쓴다(cwdMode). `Object.fromEntries`·JSON 왕복은 **정수 모양 열쇠**를 앞으로 옮겨 그 차례를
1880
+ // 흐트러뜨린다 — 실제 cwd 는 절대 경로라 그런 열쇠가 안 생기지만, 대응표의 hist 를 이어 셀 때(C8) 이 전제에 기대고 있다.
1881
+ const cwdsRaw = new Map(Object.entries(opts?.hist && typeof opts.hist === 'object' ? opts.hist : {})
1882
+ .filter(([k, v]) => k && Number.isInteger(v) && v > 0));
1719
1883
  let redacted = 0, dropped = 0, no = 0;
1720
1884
  for (const line of raw.split('\n')) {
1721
1885
  no++;
@@ -1731,8 +1895,6 @@ function scrubTranscript(raw) {
1731
1895
  * (그것도 152줄을 싣는다) 버린 뒤에 세면 표본이 **조용히** 줄어든다. 순서가 곧 방어다.
1732
1896
  */
1733
1897
  if (typeof r.cwd === 'string' && r.cwd) {
1734
- const k = relHome(r.cwd);
1735
- cwds.set(k, (cwds.get(k) || 0) + 1);
1736
1898
  cwdsRaw.set(r.cwd, (cwdsRaw.get(r.cwd) || 0) + 1);
1737
1899
  }
1738
1900
  if (DROP_TYPES.has(r.type) && !isKeptAttachment(r)) { dropped++; continue; }
@@ -1785,11 +1947,15 @@ function scrubTranscript(raw) {
1785
1947
  redacted += extra;
1786
1948
  out.push(JSON.stringify(safe));
1787
1949
  }
1788
- if (!out.length) throw new Error('빈 트랜스크립트입니다.');
1789
- // `cwds_raw` 는 **이 대화가 거쳐 간 자리 전부**다(빈도순). 최빈 자리 하나만 보면 세션의 일부가
1790
- // `.genetics-private` 폴더에서 돌아도 그 대화가 통째로 올라간다(개인정보 검토 m1).
1791
- const rawAll = [...cwdsRaw.entries()].sort((a, b) => b[1] - a[1]).map(([k]) => k).slice(0, 64);
1792
- return { jsonl: out.join('\n') + '\n', lines: out.length, dropped, redacted, cwd: cwdMode(cwds), cwd_raw: cwdMode(cwdsRaw), cwds_raw: rawAll };
1950
+ if (!out.length && !delta) throw new Error('빈 트랜스크립트입니다.');
1951
+ // 집 경로를 접은 셈은 원문 셈에서 낸다 — 이어 센 hist 에서도 전체와 같은 최빈 자리가 나온다(처음 본 차례도 같다).
1952
+ const cwds = new Map();
1953
+ for (const [k, v] of cwdsRaw) { const h = relHome(k); cwds.set(h, (cwds.get(h) || 0) + v); }
1954
+ // `cwds_raw` 는 **이 대화가 거쳐 간 자리 전부**다(빈도순 · 자르지 않는다). 최빈 자리 하나만 보면 세션의 일부가
1955
+ // `.genetics-private` 폴더에서 돌아도 그 대화가 통째로 올라간다(개인정보 검토 m1). 판정의 정본은 `cwd_hist` 의 열쇠다.
1956
+ const rawAll = [...cwdsRaw.entries()].sort((a, b) => b[1] - a[1]).map(([k]) => k);
1957
+ return { jsonl: out.length ? out.join('\n') + '\n' : '', lines: out.length, dropped, redacted,
1958
+ cwd: cwdMode(cwds), cwd_raw: cwdMode(cwdsRaw), cwds_raw: rawAll, cwd_hist: Object.fromEntries(cwdsRaw) };
1793
1959
  }
1794
1960
 
1795
1961
  /*
@@ -2097,6 +2263,768 @@ function splitAtLines(text, max) {
2097
2263
  return out.length ? out : [text];
2098
2264
  }
2099
2265
 
2266
+ /* ── 좌표·기계 키·대응표 (DESIGN-session-delta-sync §2.1·§2.9·§2.11 · C6) ────────────────────────────
2267
+ *
2268
+ * 이음매는 **원본 전사의 바이트 오프셋**이다. `raw_off` 는 늘 마지막 `\n` 바로 뒤에 멈춘다(쓰는 중인 반쪽 줄은 다음 바퀴).
2269
+ * 기반의 정체는 기기 이름이 아니라 **기계 키의 지문**이 보증한다 — `deviceId` 는 유일하지도 안정적이지도 않다.
2270
+ * · 키: `<설정 디렉터리>/push/_key` — 0600, 32바이트 난수, **처음 FULL 을 보낼 때** 만든다(모듈을 불러오는 것만으로는 안 만든다
2271
+ * — 시험들이 진짜 HOME 인 채 이 파일을 import 한다).
2272
+ * · raw_key_id = HMAC(key, 'genetics-push/key-id') 앞 16hex — 이 기계의 좌표인지 가르는 이름표일 뿐이다.
2273
+ * · raw_head_mac = HMAC(key, 원본[0, min(64KB, to))) ← 64KB 보다 짧은 전사는 [0,to) 다(덧붙임이 같은 규칙으로 잰다).
2274
+ * · raw_win_mac = HMAC(key, 원본[max(0, to−64KB), to))
2275
+ * · 원본 바이트의 맨 sha 는 **서버에 안 보낸다**(privacy) — 서버는 키가 없어 지문으로 원문을 대조해 볼 수 없다.
2276
+ * 대응표 `<설정 디렉터리>/push/<uuid>.json` 은 **좌표 캐시일 뿐**이다 — 정책 상태(간격·백오프·막음)는 색인 `auto.*` 에 그대로 둔다.
2277
+ * 자리는 자격 파일 옆(`dirname(CRED_FILE)`, 색인과 같은 규칙) — `GENETICS_CRED` 하나로 시험이 격리된다. `GENETICS_PUSH_DIR` 는
2278
+ * 같은 프로세스 안에서 자리를 갈아 끼우는 시험용 손잡이다(부를 때마다 읽는다).
2279
+ */
2280
+ const PUSH_MAC_WIN = 64 * 1024;
2281
+ /** 거쳐 간 자리(cwd_hist 열쇠)가 이보다 많으면 자동 올리기는 닫힌 쪽으로 멈춘다(§2.2 `cwd_hist_overflow`). */
2282
+ const CWD_HIST_MAX = 4096;
2283
+ const PUSH_FULL_LOG_MAX = 16;
2284
+
2285
+ function pushDir() {
2286
+ return process.env.GENETICS_PUSH_DIR || path.join(path.dirname(CRED_FILE), 'push');
2287
+ }
2288
+
2289
+ let pushKeyMemo = null; // { file, key } — 자리가 바뀌면(시험) 다시 읽는다
2290
+ /**
2291
+ * 기계 키 — 없으면 만든다(`create`). 못 만들거나 못 읽으면 **null**: 부르는 쪽은 좌표 없이 옛 봉투로 보낸다(예외를 안 낸다).
2292
+ * 만들기는 임시 파일(0600)을 다 채운 뒤 `link` 로 건다 — 이미 있으면 EEXIST 로 지고 남의 키를 읽는다(경합에서 반쪽 키를 안 본다).
2293
+ */
2294
+ function pushKey(create = true) {
2295
+ const dir = pushDir();
2296
+ const file = path.join(dir, '_key');
2297
+ if (pushKeyMemo && pushKeyMemo.file === file) return pushKeyMemo.key;
2298
+ let key = null;
2299
+ try { key = fs.readFileSync(file); } catch (e) {
2300
+ if (e?.code !== 'ENOENT') warnOnce('push-key-read', `기계 키(${file})를 못 읽어 좌표 없이 올립니다: ${e?.code || e}`);
2301
+ if (e?.code !== 'ENOENT' || !create) return null;
2302
+ }
2303
+ if (!key) {
2304
+ const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
2305
+ try {
2306
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
2307
+ const fd = fs.openSync(tmp, 'wx', 0o600);
2308
+ try { fs.fchmodSync(fd, 0o600); fs.writeSync(fd, crypto.randomBytes(32)); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
2309
+ try { fs.linkSync(tmp, file); } catch (e) { if (e?.code !== 'EEXIST') throw e; }
2310
+ } catch (e) {
2311
+ // 좌표 없이도 올리기는 된다(개인정보 쪽으로는 안전한 방향) — 다만 말없이 영영 꺼지지 않게 한 줄은 남긴다
2312
+ warnOnce('push-key-make', `기계 키를 못 만들어 좌표 없이 올립니다(${dir}): ${e?.code || e}`);
2313
+ return null;
2314
+ } finally { try { fs.rmSync(tmp, { force: true }); } catch { /* */ } }
2315
+ try { key = fs.readFileSync(file); } catch { return null; }
2316
+ }
2317
+ if (key.length !== 32) { // 망가진 키는 안 쓴다(갈아 끼우지도 않는다 — 좌표 없이 FULL 로 산다)
2318
+ warnOnce('push-key-bad', `기계 키(${file})가 32바이트가 아니라 좌표 없이 올립니다 — 지우면 다음 올리기에 새로 만듭니다.`);
2319
+ return null;
2320
+ }
2321
+ // 백업에서 되살리거나 손으로 복사한 키는 0644 일 수 있다 — 쓰기 전에 조인다(키·자리 모두). 못 조여도 올리기는 막지 않는다.
2322
+ for (const [p, want] of [[file, 0o600], [dir, 0o700]]) {
2323
+ try {
2324
+ if (fs.statSync(p).mode & 0o077) { fs.chmodSync(p, want); warnOnce(`push-key-mode:${p}`, `${p} 의 권한이 넓어 ${want.toString(8)} 으로 조였습니다.`); }
2325
+ } catch { /* */ }
2326
+ }
2327
+ pushKeyMemo = { file, key };
2328
+ return key;
2329
+ }
2330
+ function pushMac(key, buf) { return crypto.createHmac('sha256', key).update(buf).digest('hex'); }
2331
+ function pushKeyId(key) { return pushMac(key, 'genetics-push/key-id').slice(0, 16); }
2332
+
2333
+ /** FULL 이 싣는 `raw` 좌표 — 서버 `agent_session_coords_parse()` 의 모양(`to` 는 JSON 정수 · MAC 은 소문자 64hex). */
2334
+ function pushCoords(key, bytes, to) {
2335
+ return {
2336
+ key_id: pushKeyId(key), to,
2337
+ head_mac: pushMac(key, bytes.subarray(0, Math.min(PUSH_MAC_WIN, to))),
2338
+ win_mac_to: pushMac(key, bytes.subarray(Math.max(0, to - PUSH_MAC_WIN), to)),
2339
+ };
2340
+ }
2341
+
2342
+ function ledgerFile(uuid) {
2343
+ const u = String(uuid || '').toLowerCase();
2344
+ return /^[0-9a-f][0-9a-f-]{7,63}$/.test(u) ? path.join(pushDir(), `${u}.json`) : '';
2345
+ }
2346
+ function ledgerRead(uuid) {
2347
+ const f = ledgerFile(uuid);
2348
+ if (!f) return null;
2349
+ try { const o = JSON.parse(fs.readFileSync(f, 'utf8')); return o && typeof o === 'object' && !Array.isArray(o) ? o : null; } catch { return null; }
2350
+ }
2351
+ function ledgerWrite(uuid, obj) {
2352
+ const f = ledgerFile(uuid);
2353
+ if (!f) return false;
2354
+ fs.mkdirSync(path.dirname(f), { recursive: true, mode: 0o700 });
2355
+ writeAtomic(f, JSON.stringify(obj), 0o600); // 임시 파일 + rename(§2.11) — 반쪽 대응표를 읽는 창이 없다
2356
+ return true;
2357
+ }
2358
+ function ledgerDrop(uuid) {
2359
+ const f = ledgerFile(uuid);
2360
+ if (f) { try { fs.rmSync(f, { force: true }); } catch { /* 캐시일 뿐이다 */ } }
2361
+ }
2362
+
2363
+ /**
2364
+ * FULL 성공 뒤 대응표를 세운다 — **서버가 그 좌표를 세웠다고 답한 때만**: 응답 `raw_off` 가 정수이고 보낸 `to` 와 같으며,
2365
+ * `raw_win_mac` 이 보낸 값과 **같을 때**(그 MAC 은 이 기계 키의 HMAC 이라, 같으면 서버 행의 key_id 도 이 키다 — 성공 응답에는
2366
+ * key_id 칸이 없어 이것으로 대신 잰다). 칸이 없거나(옛 서버) null 이면(누적 상한·cwd 비밀 모양) 옛 대응표를 **지운다** —
2367
+ * 서버 행의 좌표가 방금 비었거나 원래 없으니 남은 캐시는 거짓 기반이다. 실패해도 올리기는 성공이다(캐시일 뿐).
2368
+ * 좌표 없이 보냈으면(`sent` null — 키 없음·Cursor 의 다 쓴 끝 줄) 역시 지운다.
2369
+ */
2370
+ function ledgerAfterFull(uuid, r, sent, ctx) {
2371
+ try {
2372
+ if (!sent || !Number.isInteger(r?.raw_off) || r.raw_off !== sent.to || !Number.isInteger(r?.rev)
2373
+ || r.raw_win_mac !== sent.win_mac_to) {
2374
+ ledgerDrop(uuid);
2375
+ return false;
2376
+ }
2377
+ return ledgerWrite(uuid, {
2378
+ v: 1, uuid, tenant_id: Number(r.tenant_id) || 0, key_id: sent.key_id, rev: r.rev,
2379
+ bytes: Number.isInteger(r.bytes) ? r.bytes : null, raw_off: r.raw_off,
2380
+ head_mac: sent.head_mac, win_mac: sent.win_mac_to, ino: ctx.ino, dev: ctx.dev,
2381
+ /*
2382
+ * ★ `head_len` — **서버가 되돌려 주지 않는 단 하나의 좌표**(C8). 덧붙임 관문은 `raw.head_mac !== row.raw_head_mac` 이면
2383
+ * base_moved 인데, 성공하면 서버는 **보낸 head_mac 을 그대로 다시 적는다**(agent_session_append.php:241). 즉 그 지문의
2384
+ * 창 길이는 **첫 FULL 의 `to` 로 세션 내내 고정**된다 — 전사가 자란 뒤 `min(64K, raw_off)` 로 다시 재면 첫 FULL 이
2385
+ * 64KB 미만이던 대화는 값이 갈려 **영원히 base_moved** 다(그리고 까닭이 「좌표가 어긋났다」로만 보인다).
2386
+ * 그래서 창 길이를 여기 적고, 덧붙임은 **그 길이로** 재서 대응표의 값과 견준 뒤 **대응표의 값을 싣는다**.
2387
+ */
2388
+ head_len: Math.min(PUSH_MAC_WIN, sent.to),
2389
+ scrub_rev: SCRUB_REV, srv_scrub_rev: typeof r.scrub_rev === 'string' ? r.scrub_rev : null,
2390
+ ck_off: sent.to, ck_sha: ctx.ck_sha, cwd_hist: ctx.cwd_hist,
2391
+ line_off: Number.isInteger(ctx.line_off) ? ctx.line_off : 0, // [0,raw_off) 의 원본 줄 수 — 변화분 오류의 줄 번호를 사람 말로 되돌린다
2392
+ head: { id: String(r.head_id || ''), ms: Number(r.head_ms) || 0 },
2393
+ /*
2394
+ * ★ **`full_log` 는 여기 없다 — 색인 `auto.full_log` 로 옮겼다**(C8 · C7 이 남긴 숙제 둘의 처방). 까닭 둘:
2395
+ * ① 이 함수는 좌표가 안 오면 대응표를 **통째로 지운다**(위) — 예산의 재료가 그때마다 0 으로 되돌아갔다.
2396
+ * ② 이 함수는 **사람의 push 도 안 가린다** — 사람이 두 번 올리면 그 대화의 자동 갱신이 한 시간 멎었다.
2397
+ * 기록하는 자는 이제 **입구 하나**(`autoEntrance`)이고, 기록하는 때는 **진짜 FULL(재기반)이 성공한 때**뿐이다.
2398
+ * 설계 §2.11 의 「정책 상태는 대응표에 두지 않는다 — 색인 `auto.*` 에 둔다」와도 같은 쪽이다.
2399
+ */
2400
+ });
2401
+ } catch { return false; }
2402
+ }
2403
+
2404
+ /* ── 능력 광고 캐시 (DESIGN-session-delta-sync §6.1 · C8) ────────────────────────────────────────────
2405
+ *
2406
+ * 새 서버는 **세션 블록 맨 앞**에서 `X-Genetics-Session-Caps: append1` 을 한 번 세운다(api.php:1304) — 자격 403 보다도,
2407
+ * `api_out` 을 안 지나는 이진 심박 응답보다도 앞이다. **광고를 본 적 없는 서버에는 덧붙임을 보내지 않는다.**
2408
+ * 판정의 규율 셋(전부 실측이 근거다):
2409
+ * ① 머리가 있으면 **참**이다 — 그 origin 에 붙박이로 적는다(메모리 + `push/_caps.json`).
2410
+ * ② 「없으니 옛 서버」는 **세션 경로의 JSON 응답이면서 401 이 아닐 때만** 적는다(1시간). ⚠ **401 에는 광고가 없다**
2411
+ * (전역 인증이 세션 블록보다 앞이다 — curl 실측) — 401 하나로 거짓을 캐시하면 토큰이 만료된 순간 그 origin 이
2412
+ * 영영 FULL 전용이 된다.
2413
+ * ③ 5xx·타임아웃·망 오류·JSON 이 아닌 응답은 **아무 판정도 아니다**(위 `api()` 의 주석 — 깨진 200 이 그 갈래다).
2414
+ * ④ 「없으니 옛 서버」는 **우리 PHP 가 답한 응답**에서만 적는다 — 표는 `X-Genetics-Min-Client`(api.php:214 가 **모든 `/api/*`,
2415
+ * 4xx 포함**에 세운다 · 능력 광고보다 먼저 들어온 머리다). 그 머리도 없는 4xx 는 가장자리(Caddy·WAF·CDN 의 403·404·429)의
2416
+ * 답이라 서버에 대해 아무것도 말하지 않는다(C8 반박 cost③ — 전에는 그 하나가 origin 을 한 시간 FULL 전용으로 만들었다).
2417
+ * ⑤ `/append` 가 광고 **없이** 400·404·405 로 거절되면(`capsAppendGone`) 그 거짓은 **1시간 동안 광고로도 못 덮는다**. 곧이어 도는
2418
+ * FULL 의 응답이 광고를 싣기 때문이다 — 안 그러면 「덧붙임만 죽은 서버」(그 경로만 막은 프록시·반쪽 배포)에서 바퀴마다
2419
+ * `/append` 를 다시 두드리고 FULL 로 내려가기를 되풀이한다(C8 반박 fail F1·equiv ④ — 거짓이 같은 바퀴 안에 지워졌다).
2420
+ * 서버의 규칙판(`scrub_rev`)도 같은 자리에 적는다 — 그것은 **서버마다 하나**인 값이고, 대응표를 잃은 따라잡기가
2421
+ * 덧붙임 봉투를 지으려면 그 값이 있어야 한다(대응표의 `srv_scrub_rev` 가 유일한 재료였다).
2422
+ */
2423
+ const CAPS_FALSE_TTL_MS = 3600_000;
2424
+ const CAPS_REV_RE = /^[A-Za-z0-9._-]{1,32}$/;
2425
+ let capsMemo = null; // origin → { append: boolean, at: number, gone?: number, scrub_rev?: string }
2426
+ let capsMemoDir = ''; // 그 메모가 읽어 온 자리 — 자리가 바뀌면(시험의 GENETICS_PUSH_DIR) 다시 읽는다
2427
+
2428
+ function capsFile() { return path.join(pushDir(), '_caps.json'); }
2429
+ function capsOrigin() { try { return new URL(BASE).origin; } catch { return String(BASE || ''); } }
2430
+ function capsLoad() {
2431
+ const dir = pushDir();
2432
+ if (capsMemo && capsMemoDir === dir) return capsMemo;
2433
+ capsMemo = new Map();
2434
+ capsMemoDir = dir;
2435
+ try {
2436
+ const o = JSON.parse(fs.readFileSync(capsFile(), 'utf8'));
2437
+ for (const [k, v] of Object.entries(o?.origins && typeof o.origins === 'object' ? o.origins : {})) {
2438
+ if (v && typeof v === 'object' && typeof v.append === 'boolean') {
2439
+ capsMemo.set(k, { append: v.append, at: Number(v.at) || 0, ...(Number(v.gone) > 0 ? { gone: Number(v.gone) } : {}),
2440
+ ...(CAPS_REV_RE.test(String(v.scrub_rev || '')) ? { scrub_rev: String(v.scrub_rev) } : {}) });
2441
+ }
2442
+ }
2443
+ } catch { /* 없거나 깨졌으면 **모르는 것**으로 — 캐시일 뿐이고 모르면 FULL 이다(닫는 쪽) */ }
2444
+ return capsMemo;
2445
+ }
2446
+ /**
2447
+ * ⚠ **자리를 만들지 않는다.** `push/` 는 기계 키와 대응표의 집이고 그 자리는 FULL 이 키를 만들 때 선다. 광고 하나 때문에 자리를
2448
+ * 만들면 **dry_run 이 자리를 안 만든다**는 계약(C6)이 깨진다 — 광고를 싣는 서버에 dry_run 을 하면 `push/_caps.json` 이 생겼다
2449
+ * (C8 반박 fail F2·cost ⑤ · smoke 의 그 단언은 가짜 서버가 광고를 안 내서 빨개질 수 없었다). 자리가 없으면 메모리로 족하다 —
2450
+ * 첫 FULL 이 키를 만들면 그다음 응답부터 적힌다.
2451
+ */
2452
+ function capsSave() {
2453
+ try {
2454
+ if (!fs.existsSync(pushDir())) return;
2455
+ writeAtomic(capsFile(), JSON.stringify({ v: 1, origins: Object.fromEntries(capsLoad()) }), 0o600);
2456
+ } catch { /* 못 적어도 이 프로세스는 메모리 캐시로 산다 */ }
2457
+ }
2458
+ /**
2459
+ * 응답 하나가 이 서버에 대해 말해 주는 것 — 규율은 위 머리말. `api()` 가 JSON 으로 읽힌 응답에서만 부른다.
2460
+ * @param {boolean} [ours] 우리 PHP 가 답했나(`X-Genetics-Min-Client` 가 있었나) — 거짓이면 「광고 없음」을 판정으로 안 쓴다(④)
2461
+ */
2462
+ function capsNote(pathname, status, header, data, ours = true) {
2463
+ if (!/^\/api\/sessions(\/|$)/.test(String(pathname || ''))) return;
2464
+ const org = capsOrigin();
2465
+ if (!org) return;
2466
+ const m = capsLoad();
2467
+ const was = m.get(org) || null;
2468
+ const has = String(header || '').split(/[\s,]+/).filter(Boolean).includes('append1');
2469
+ // 서버 판(`scrub_rev`)은 좌표를 실은 전체 PUT 의 성공 응답이 준다 — 모양이 맞을 때만 받는다.
2470
+ const rev = typeof data?.scrub_rev === 'string' && CAPS_REV_RE.test(data.scrub_rev) ? data.scrub_rev : (was?.scrub_rev || '');
2471
+ const s = Number(status) || 0;
2472
+ const now = Date.now();
2473
+ // ⑤ `/append` 가 광고 없이 거절된 뒤 1시간은 광고도 그 거짓을 못 덮는다(그 시각이 `gone`).
2474
+ const goneHold = was?.append === false && Number(was.gone) > 0 && now - Number(was.gone) < CAPS_FALSE_TTL_MS;
2475
+ let next = null;
2476
+ if (has) next = goneHold ? null : { append: true, at: now };
2477
+ else if (ours && s !== 401 && ((s >= 200 && s < 300) || (s >= 400 && s < 500))) {
2478
+ next = { append: false, at: now, ...(goneHold ? { gone: Number(was.gone) } : {}) };
2479
+ }
2480
+ if (!next) { if (rev && rev !== was?.scrub_rev) { m.set(org, { ...(was || { append: false, at: 0 }), scrub_rev: rev }); capsSave(); } return; }
2481
+ if (rev) next.scrub_rev = rev;
2482
+ // 같은 답이 되풀이되면 파일을 다시 안 적는다 — 거짓은 1시간마다 한 번만 새로 적는다(그 TTL 이 새로 올라간 서버를 다시 본다).
2483
+ if (was && was.append === next.append && was.scrub_rev === next.scrub_rev
2484
+ && (next.append === true || now - was.at < CAPS_FALSE_TTL_MS)) return;
2485
+ m.set(org, next);
2486
+ capsSave();
2487
+ }
2488
+ /** 이 서버가 덧붙임을 받는다고 **광고했나** — 모르면 거짓이다(닫는 쪽: FULL). */
2489
+ function capsAppend() { return capsLoad().get(capsOrigin())?.append === true; }
2490
+ /** 이 서버의 규칙판(`AGENT_SCRUB_REV`) — 못 봤으면 ''. 덧붙임 봉투의 `scrub_rev` 는 **클라이언트 판(m1)이 아니라 이것**이다. */
2491
+ function capsScrubRev() { return String(capsLoad().get(capsOrigin())?.scrub_rev || ''); }
2492
+ /**
2493
+ * `/append` 가 **광고 없이** 400·404·405 — 이 서버(또는 그 앞의 무엇)는 덧붙임을 모른다(§5.1). 1시간 거짓으로 덮고 이번 바퀴는 FULL.
2494
+ * ⚠ 그 1시간은 **광고로도 못 덮는다**(`gone` · 머리말 ⑤) — 그래서 그동안은 `/append` 를 안 두드리고 옛 서버처럼 FULL 만 간다.
2495
+ */
2496
+ function capsAppendGone() {
2497
+ const m = capsLoad();
2498
+ const was = m.get(capsOrigin());
2499
+ const now = Date.now();
2500
+ m.set(capsOrigin(), { append: false, at: now, gone: now, ...(was?.scrub_rev ? { scrub_rev: was.scrub_rev } : {}) });
2501
+ capsSave();
2502
+ }
2503
+
2504
+ /* ── 덧붙임 길 (DESIGN-session-delta-sync §2.10 · C8) ────────────────────────────────────────────── */
2505
+
2506
+ /** 【선언】 한 조각의 **JSON 봉투** 상한. 서버가 재는 것은 `strlen($delta)` 6MiB 인데, POST 는 그 앞에서 PHP `post_max_size`
2507
+ * (dev 실측 8MiB · **운영 값 미확인**)에 먼저 걸린다 — 넘으면 본문이 통째로 버려지고 `text/html` 경고 덩어리가 200 으로 온다.
2508
+ * 그래서 나누는 자는 검열본 바이트가 아니라 **봉투 바이트**다(실측 부풂 1.016~1.036배 · 깨지는 문턱 1.3333배). */
2509
+ const APPEND_ENV_MAX = 6 * 1024 * 1024 + 512 * 1024;
2510
+ /** 조각 하나가 읽는 원본 구간의 몫 — 검열 출력은 이보다 작거나 비슷하다. 봉투가 상한을 넘으면 줄 경계에서 반씩 더 쪼갠다. */
2511
+ const APPEND_RAW_STEP = 4 * 1024 * 1024;
2512
+ /** 한 바퀴에 덧붙이는 원본의 상한 — 나머지는 다음 바퀴로. 메모리와 임대 묵음 한도를 함께 묶는다. */
2513
+ const APPEND_ROUND_MAX = 32 * 1024 * 1024;
2514
+ /** 흘려 읽기의 걸음(§2.10 「1MB 단위로 흘려 읽고」). */
2515
+ const APPEND_READ_CHUNK = 1024 * 1024;
2516
+ /** 【선언】 FULL 예산 — **진짜 FULL(재기반)** 만 시간당 이만큼(§5 불변식 「전체 재전송으로 폭주하지 않는다」). */
2517
+ const PUSH_FULL_BUDGET = 2;
2518
+ const PUSH_FULL_WINDOW_MS = 3600_000;
2519
+ /**
2520
+ * 【선언】 예산이 세는 FULL 의 까닭 — **재기반**과, **덧붙임을 아는 서버가 이 대화의 덧붙임을 거절한 것**(`append_rejected` —
2521
+ * 거절 응답이 광고를 실은 400·404·405. 바퀴마다 같은 거절을 받고 통째로 내려가는 대화를 묶는다 · C8 반박 fail F1).
2522
+ * 여기 없는 까닭은 구조가 정한 것이라 예산으로 늦춰 봐야 아무도 안 구한다:
2523
+ * `no_cap`(옛 서버 — 그 서버에서는 모든 올리기가 FULL 이다 · 광고 없는 `/append` 거절도 1시간 이 갈래다) · `no_key`(기계 키 없음) ·
2524
+ * `cursor_tail`(Cursor 의 다 쓴 끝 줄 — 덧붙임은 `\n` 경계만 받는다) · `title`(사람이 제목을 고친다 — 입구는 제목을 안 싣는다).
2525
+ * 첫 업로드도 안 센다 — 그 판정은 입구가 한다(`autoEntrance`).
2526
+ */
2527
+ const PUSH_FULL_REBASE = new Set(['no_base', 'tenant', 'rebake', 'replaced', 'shrunk', 'head_changed', 'seam_changed',
2528
+ 'prefix_changed', 'base_moved', 'base_lost', 'append_rejected']);
2529
+
2530
+ /** 버퍼 안의 줄 수(모든 줄이 `\n` 으로 끝난다 — 조각은 줄 경계에서만 끊긴다). */
2531
+ function countLines(buf) {
2532
+ let n = 0;
2533
+ for (let i = buf.indexOf(0x0a); i >= 0; i = buf.indexOf(0x0a, i + 1)) n++;
2534
+ return n;
2535
+ }
2536
+
2537
+ /** 파일 [from, to) 의 줄 수 — 흘려 읽고 **검열은 안 한다**(서버가 뒤처진 따라잡기가 `line_off` 를 되돌릴 때만 쓴다). */
2538
+ function countLinesAt(fd, from, to) {
2539
+ const buf = Buffer.allocUnsafe(APPEND_READ_CHUNK);
2540
+ let n = 0;
2541
+ let at = from;
2542
+ while (at < to) {
2543
+ const got = fs.readSync(fd, buf, 0, Math.min(buf.length, to - at), at);
2544
+ if (got <= 0) break;
2545
+ n += countLines(got === buf.length ? buf : buf.subarray(0, got));
2546
+ at += got;
2547
+ }
2548
+ return n;
2549
+ }
2550
+
2551
+ /** 파일의 [pos, pos+len) 를 읽는다 — 자리 읽기라 fd 의 자리를 안 옮긴다(덧붙임은 전사를 통째로 안 읽는다). */
2552
+ function readAt(fd, pos, len) {
2553
+ const want = Math.max(0, Number(len) || 0);
2554
+ if (want === 0) return Buffer.alloc(0);
2555
+ const b = Buffer.allocUnsafe(want);
2556
+ let got = 0;
2557
+ while (got < want) {
2558
+ const n = fs.readSync(fd, b, got, want - got, pos + got);
2559
+ if (n <= 0) break;
2560
+ got += n;
2561
+ }
2562
+ return got === want ? b : b.subarray(0, got);
2563
+ }
2564
+
2565
+ /** 파일의 **마지막 `\n` 바로 뒤**(§2.1) — 꼬리부터 64KB 씩 거슬러 찾는다(전사를 통째로 안 읽는다). 없으면 0. */
2566
+ function lastNewlineAt(fd, size) {
2567
+ let end = Number(size) || 0;
2568
+ while (end > 0) {
2569
+ const start = Math.max(0, end - PUSH_MAC_WIN);
2570
+ const b = readAt(fd, start, end - start);
2571
+ const i = b.lastIndexOf(0x0a);
2572
+ if (i >= 0) return start + i + 1;
2573
+ end = start;
2574
+ }
2575
+ return 0;
2576
+ }
2577
+
2578
+ /**
2579
+ * [from, to) 를 **1MB 씩 흘려 읽어** 줄 경계 조각으로 넘긴다(§2.10). 조각 하나는 `step` 을 넘어서는 첫 줄 끝에서 끊고,
2580
+ * 통틀어 `cap` 을 넘으면 **거기서 멈춘다**(나머지는 다음 바퀴 — 메모리와 임대 묵음 한도를 함께 묶는다).
2581
+ * 접기(따라잡기)는 `cap` 없이 훑기만 하고, 보내기는 `cap` 안에서 조각을 모은다.
2582
+ * @param {(seg:{from:number,to:number,buf:Buffer}) => void} onSeg
2583
+ */
2584
+ function appendScan(fd, from, to, step, cap, onSeg) {
2585
+ const chunk = Buffer.allocUnsafe(APPEND_READ_CHUNK);
2586
+ let at = from;
2587
+ let segStart = from;
2588
+ let acc = [];
2589
+ let accLen = 0;
2590
+ while (at < to) {
2591
+ const n = fs.readSync(fd, chunk, 0, Math.min(chunk.length, to - at), at);
2592
+ if (n <= 0) break;
2593
+ let used = 0;
2594
+ let scan = 0;
2595
+ for (;;) {
2596
+ const i = chunk.indexOf(0x0a, scan);
2597
+ if (i < 0 || i >= n) break;
2598
+ const cut = at + i + 1;
2599
+ if (cut - segStart >= step) {
2600
+ acc.push(Buffer.from(chunk.subarray(used, i + 1)));
2601
+ accLen += (i + 1) - used;
2602
+ onSeg({ from: segStart, to: cut, buf: Buffer.concat(acc, accLen) });
2603
+ acc = []; accLen = 0; segStart = cut; used = i + 1;
2604
+ if (cut - from >= cap) return;
2605
+ }
2606
+ scan = i + 1;
2607
+ }
2608
+ if (used < n) { acc.push(Buffer.from(chunk.subarray(used, n))); accLen += n - used; }
2609
+ at += n;
2610
+ }
2611
+ if (accLen > 0) onSeg({ from: segStart, to: segStart + accLen, buf: Buffer.concat(acc, accLen) });
2612
+ }
2613
+
2614
+ /** 조각 하나를 **줄 경계에서** 반으로 쪼갠다 — 봉투가 상한을 넘거나 413 을 받았을 때. 줄 하나뿐이면 null(더 못 쪼갠다). */
2615
+ function splitSeg(seg) {
2616
+ const half = Math.floor(seg.buf.length / 2);
2617
+ let cut = seg.buf.indexOf(0x0a, half);
2618
+ if (cut < 0 || cut + 1 >= seg.buf.length) cut = seg.buf.lastIndexOf(0x0a, Math.max(0, half - 1));
2619
+ if (cut < 0 || cut + 1 >= seg.buf.length) return null;
2620
+ const n = cut + 1;
2621
+ return [{ from: seg.from, to: seg.from + n, buf: seg.buf.subarray(0, n) },
2622
+ { from: seg.from + n, to: seg.to, buf: seg.buf.subarray(n) }];
2623
+ }
2624
+
2625
+ /**
2626
+ * 변화분 검열 — **줄 번호를 사람 말로 되돌린다**(C6 이 C8 에 넘긴 것). `scrubTranscript` 안의 `no` 는 **이 호출 안의 번호**라,
2627
+ * 덧붙임에서는 「3번째 줄」이 전사의 3번째 줄이 아니다. 보정을 **부르는 쪽에서** 한다 — 그 함수는 `scrub-rev.lock` 의 원천이라
2628
+ * 한 글자만 고쳐도 판(`SCRUB_REV`)을 올려야 하고, 판을 올리면 **이 기계의 모든 대응표가 다음 바퀴에 rebake→FULL** 이 된다.
2629
+ * 줄 번호 하나 때문에 치를 값이 아니다(C8 이 정한 자리).
2630
+ */
2631
+ function scrubDelta(text, hist, lineBase) {
2632
+ try { return scrubTranscript(text, { delta: true, hist }); } catch (e) {
2633
+ const m = /^(\d+)번째 줄을/.exec(String(e?.message || ''));
2634
+ if (m && Number(lineBase) > 0) throw new Error(String(e.message).replace(/^\d+번째 줄을/, `${Number(m[1]) + Number(lineBase)}번째 줄을`));
2635
+ throw e;
2636
+ }
2637
+ }
2638
+
2639
+ /**
2640
+ * **건너뛴 원본 구간을 `cwd_hist` 에 접는다** — 셈만 하고 검열 출력은 버린다. 전송은 없다(§2.10 따라잡기 · privacy B4).
2641
+ * 이것이 있어야 그 구간에만 나온 자리도 `.genetics-private` 매 바퀴 재검사가 본다 — **좌표를 옮기기 전에** 부른다.
2642
+ * @returns {{hist: object, sc: object, lines: number}}
2643
+ */
2644
+ function foldHist(fd, from, to, hist, lineBase) {
2645
+ let h = hist && typeof hist === 'object' ? hist : {};
2646
+ let lines = 0;
2647
+ let sc = null;
2648
+ appendScan(fd, from, to, APPEND_RAW_STEP, Infinity, (seg) => {
2649
+ sc = scrubDelta(seg.buf.toString('utf8'), h, lineBase + lines);
2650
+ h = sc.cwd_hist;
2651
+ lines += countLines(seg.buf);
2652
+ });
2653
+ // 접을 것이 없어도 판정 재료는 있어야 한다 — 빈 변화분으로 `cwd`·`cwd_raw` 를 hist 에서 낸다.
2654
+ if (!sc) sc = scrubTranscript('', { delta: true, hist: h });
2655
+ return { hist: h, sc, lines };
2656
+ }
2657
+
2658
+ /**
2659
+ * 이 바퀴는 **덧붙임인가 전체인가**(§2.10 의 의사코드 그대로) — 임대 **안에서만** 부른다(§3.2 「순서가 보증이다」).
2660
+ * 파일은 **자리 읽기**로만 본다(앞 64KB · 이음매 64KB · 기하 시점의 접두) — 전사를 통째로 안 읽는 것이 덧붙임의 요지다.
2661
+ * 갈래마다 FULL 의 **까닭**을 남긴다: 예산(`PUSH_FULL_REBASE`)과 시험이 그 글자로 갈린다.
2662
+ *
2663
+ * @param {number} fd 열린 전사
2664
+ * @param {{size:bigint, ino:bigint, dev:bigint}} st 같은 fd 의 fstat
2665
+ * @param {{tenantId:number, tailDone:boolean, titled?:boolean, onServer:boolean}} o
2666
+ * @returns {{mode:'FULL'|'APPEND'|'ASK', why:string, L:object|null, key:Buffer|null, head_len:number, ck:?{off:number,sha:string}}}
2667
+ */
2668
+ function planPush(uuid, fd, st, o) {
2669
+ const FULL = (why) => ({ mode: 'FULL', why, L: null, key: null, head_len: 0, ck: null });
2670
+ // ⚠ **사람이 제목을 주면 FULL 이다**(C8 반박 equiv ②). 덧붙임은 메타를 안 바꾼다(agent_session_append.php — UPDATE 에 title 칸이
2671
+ // 없다 · 의도다). 덧붙임으로 보내면 도구는 성공이라 말하고 제목은 **조용히** 그대로였다. 입구는 제목을 안 싣으므로 예산과 무관하다.
2672
+ if (o.titled) return FULL('title');
2673
+ // ⚠ Cursor 의 **다 쓴 끝 줄**은 개행이 없다(C6). 덧붙임은 `\n` 경계만 받으므로 대응표가 있어도 FULL 이다 — 반쪽 줄을 보내면
2674
+ // 서버가 그것을 온전한 줄로 굳히고 좌표는 줄 중간을 가리킨 채 다음 바퀴가 그 줄 꼬리를 또 보낸다(거절이 아니라 조용한 중복).
2675
+ if (o.tailDone) return FULL('cursor_tail');
2676
+ if (!capsAppend()) return FULL('no_cap');
2677
+ const key = pushKey(false);
2678
+ if (!key) return FULL('no_key');
2679
+ const keyId = pushKeyId(key);
2680
+ const L = ledgerRead(uuid);
2681
+ // 대응표가 없거나 이 기계의 것이 아니면 — 서버에 이미 있는 대화면 **물어본다**(base.rev −1 · 쓰기 없음 · race S8).
2682
+ const noBase = () => (o.onServer ? { mode: 'ASK', why: 'no_base', L: null, key, head_len: 0, ck: null } : FULL('no_base'));
2683
+ if (!L || L.v !== 1 || L.key_id !== keyId) return noBase();
2684
+ if (!Number.isInteger(L.rev) || L.rev < 0 || !Number.isInteger(L.bytes) || L.bytes < 0) return noBase();
2685
+ if (!Number.isInteger(L.raw_off) || L.raw_off <= 0) return noBase();
2686
+ if (typeof L.srv_scrub_rev !== 'string' || !CAPS_REV_RE.test(L.srv_scrub_rev)) return noBase();
2687
+ // 개인정보(§5 불변식) — `cwd_hist` 가 없으면 `.genetics-private` 판정 재료가 없다. **올리지 않는다**(따라잡기가 되살린다).
2688
+ if (!L.cwd_hist || typeof L.cwd_hist !== 'object' || Array.isArray(L.cwd_hist)) return noBase();
2689
+ // ⚠ 대응표 열쇠는 uuid 하나인데 서버 행은 (소속처, uuid) 다(C6 이 C8 에 넘긴 반박 ①) — 소속처부터 본다.
2690
+ if (!(Number(o.tenantId) > 0) || Number(L.tenant_id) !== Number(o.tenantId)) return FULL('tenant');
2691
+ if (L.scrub_rev !== SCRUB_REV) return FULL('rebake'); // 판이 둘이다 — 여기는 **클라이언트 판**(C6)
2692
+ if (String(L.ino) !== String(st.ino) || String(L.dev) !== String(st.dev)) return FULL('replaced');
2693
+ if (Number(st.size) < L.raw_off) return FULL('shrunk');
2694
+ // 앞 64KB — 창 길이는 **첫 FULL 이 정한 그 길이**다(위 `head_len` 주석). 옛 대응표(C6)는 `ck_off == 첫 FULL 의 to` 라 거기서 낸다.
2695
+ const headLen = Number.isInteger(L.head_len) && L.head_len > 0
2696
+ ? Math.min(L.head_len, L.raw_off)
2697
+ : Math.min(PUSH_MAC_WIN, Number.isInteger(L.ck_off) && L.ck_off > 0 ? L.ck_off : L.raw_off);
2698
+ if (pushMac(key, readAt(fd, 0, headLen)) !== L.head_mac) return FULL('head_changed');
2699
+ // 이음매 64KB — **이것이 「내가 아는 그 자리가 아직 그대로인가」의 탐지기**다. 다르면 보내지 말고 바로 FULL.
2700
+ const wf = Math.max(0, L.raw_off - PUSH_MAC_WIN);
2701
+ if (pushMac(key, readAt(fd, wf, L.raw_off - wf)) !== L.win_mac) return FULL('seam_changed');
2702
+ // 기하 시점 검사(§3.6) — `raw_off ≥ 2·ck_off` 면 [0,ck_off) 를 다시 해시해 대조하고 **같은 읽기로** 새 ck 를 세운다.
2703
+ // 창 밖에서 크기를 유지한 채 제자리에서 다시 쓴 경우를 늦게라도 잡는 자리다. 일생 동안 읽는 총량은 2N 이하다.
2704
+ let ck = null;
2705
+ if (Number.isInteger(L.ck_off) && L.ck_off > 0 && L.raw_off >= 2 * L.ck_off) {
2706
+ const h = crypto.createHash('sha256');
2707
+ const buf = Buffer.allocUnsafe(APPEND_READ_CHUNK);
2708
+ let at = 0;
2709
+ let seen = '';
2710
+ while (at < L.raw_off) {
2711
+ const n = fs.readSync(fd, buf, 0, Math.min(buf.length, L.raw_off - at), at);
2712
+ if (n <= 0) break;
2713
+ if (at < L.ck_off && at + n >= L.ck_off) {
2714
+ h.update(buf.subarray(0, L.ck_off - at));
2715
+ seen = h.copy().digest('hex');
2716
+ h.update(buf.subarray(L.ck_off - at, n));
2717
+ } else h.update(buf.subarray(0, n));
2718
+ at += n;
2719
+ }
2720
+ if (seen !== L.ck_sha) return FULL('prefix_changed');
2721
+ ck = { off: L.raw_off, sha: h.digest('hex') };
2722
+ }
2723
+ return { mode: 'APPEND', why: '', L, key, head_len: headLen, ck };
2724
+ }
2725
+
2726
+ /**
2727
+ * 이 올리기가 가는 소속처의 **숫자 id** — 대응표의 `tenant_id` 와 견줄 값. 입구는 그것을 알고 부른다(`tid`);
2728
+ * 사람의 `push_session` 은 슬러그·이름만 아는 채로 오므로 색인 행에서 **그 이름이 그 행의 소속처일 때만** 낸다.
2729
+ * 모르면 0 이고, 그러면 덧붙이지 않는다(닫는 쪽) — 남의 소속처 행에 이 기계의 좌표를 들이밀지 않는다.
2730
+ */
2731
+ function pushTenantId(tenantArg, uuid, hint) {
2732
+ if (Number(hint) > 0) return Number(hint);
2733
+ const a = String(tenantArg || '').trim().toLowerCase();
2734
+ if (!a) return 0;
2735
+ const row = indexRead()[String(uuid)];
2736
+ if (!row || typeof row !== 'object' || !(Number(row.tenant_id) > 0)) return 0;
2737
+ return (String(row.tenant_slug || '').toLowerCase() === a || String(row.tenant || '').toLowerCase() === a)
2738
+ ? Number(row.tenant_id) : 0;
2739
+ }
2740
+
2741
+ /** [from,to) 의 sha256 — 흘려 읽는다(따라잡기 뒤 기하 점검 자리를 다시 세울 때만 쓴다). */
2742
+ function hashRange(fd, from, to) {
2743
+ const h = crypto.createHash('sha256');
2744
+ const buf = Buffer.allocUnsafe(APPEND_READ_CHUNK);
2745
+ let at = from;
2746
+ while (at < to) {
2747
+ const n = fs.readSync(fd, buf, 0, Math.min(buf.length, to - at), at);
2748
+ if (n <= 0) break;
2749
+ h.update(buf.subarray(0, n));
2750
+ at += n;
2751
+ }
2752
+ return h.digest('hex');
2753
+ }
2754
+
2755
+ /**
2756
+ * **덧붙임 길**(§2.10 · C8) — `[raw_off, EOF)` 를 1MB 로 흘려 읽어 **마지막 `\n` 까지만** 줄 경계 조각으로 나누고,
2757
+ * 조각마다 `POST /api/sessions/append` 를 **차례로** 보낸다. 요청 하나가 커밋 하나다 — 앞 응답의 `rev`·`bytes`·`raw_off` 가
2758
+ * 다음 요청의 기반이라 **병렬로 못 보낸다**(조각 길과 다른 점이 그것이다).
2759
+ *
2760
+ * 순서가 보증이다: ① 조각을 다 **검열**해 `cwd_hist` 를 이어 세고 → ② 그 열쇠 **전부**로 사적 폴더·4096 을 판정하고 →
2761
+ * ③ 그다음에 첫 바이트를 보낸다. 거꾸로 하면 둘째 조각에만 나온 사적 자리가 첫 조각이 나간 뒤에 드러난다.
2762
+ *
2763
+ * ⚠ 제목은 이 길로 안 온다 — 제목이 있으면 `planPush` 이 FULL(`title`)을 고른다(덧붙임은 메타를 안 바꾼다).
2764
+ *
2765
+ * @param {{fd:number, st:object, uuid:string, tenant:string, tenantId:number, dryRun:boolean,
2766
+ * bindOf:'propose'|'skip', auto:?object, hooks:?object, headers:?object, plan:object,
2767
+ * size:number, end:number, isCursorBody:boolean}} a
2768
+ * @returns {Promise<{r:?object, before:number, parts:number, raw_sha:string, plan:object, appended:number, why:string}>}
2769
+ * `r` 이 있으면 보냈다. 없으면 `why` 가 **같은 부름 안에서 FULL 로 내려가는 까닭**이다(예산이 그 글자로 갈린다).
2770
+ */
2771
+ async function pushAppend(a) {
2772
+ const { fd, st, uuid, tenant, tenantId, dryRun, bindOf, auto, hooks, headers, plan, size, end, isCursorBody } = a;
2773
+ const key = plan.key;
2774
+ const keyId = pushKeyId(key);
2775
+ const L = plan.L;
2776
+ const progressed = () => { try { hooks?.progress?.(); } catch { /* 임대 시계를 못 밀어도 올리기는 계속한다 */ } };
2777
+ const fall = (why) => ({ r: null, why, before: size, parts: 0, raw_sha: '', plan, appended: 0 });
2778
+ const stop = (code, msg) => { throw Object.assign(new Error(msg), { code, status: code === 'session_hidden' ? 409 : 0 }); };
2779
+
2780
+ let headMac = L ? String(L.head_mac) : '';
2781
+ let headLen = plan.head_len;
2782
+ let srvRev = L ? String(L.srv_scrub_rev) : capsScrubRev();
2783
+ let from = L ? L.raw_off : 0;
2784
+ let base = L ? { rev: L.rev, bytes: Number(L.bytes) || 0 } : { rev: -1, bytes: 0 };
2785
+ let winFrom = L ? String(L.win_mac) : '';
2786
+ let lineOff = L && Number.isInteger(L.line_off) ? L.line_off : 0;
2787
+ let ck = plan.ck || (L && Number.isInteger(L.ck_off) && L.ck_off > 0 ? { off: L.ck_off, sha: String(L.ck_sha || '') } : null);
2788
+ let hist = L && L.cwd_hist && typeof L.cwd_hist === 'object' ? { ...L.cwd_hist } : {};
2789
+ let headInfo = L && L.head && typeof L.head === 'object' ? L.head : { id: '', ms: 0 };
2790
+
2791
+ // ⚠ **dry_run 은 대응표를 안 만든다**(C6 의 계약 — FULL 쪽 주석과 같은 규칙). 전에는 대응표 없는 dry_run 의 물음(ASK)이
2792
+ // 서버 좌표를 받아 `push/<uuid>.json` 을 세웠다(C8 반박 fail F2·equiv ⑧·cost ⑤).
2793
+ const ledgerSave = () => (dryRun ? false : ledgerWrite(uuid, {
2794
+ v: 1, uuid, tenant_id: tenantId, key_id: keyId, rev: base.rev, bytes: base.bytes, raw_off: from,
2795
+ head_mac: headMac, head_len: headLen, win_mac: winFrom, ino: String(st.ino), dev: String(st.dev),
2796
+ scrub_rev: SCRUB_REV, srv_scrub_rev: srvRev,
2797
+ ck_off: ck ? ck.off : 0, ck_sha: ck ? ck.sha : '',
2798
+ cwd_hist: hist, line_off: lineOff, head: headInfo,
2799
+ }));
2800
+
2801
+ /**
2802
+ * **따라잡기** — 서버 좌표가 이 기계 키이고 그 자리의 이음매 MAC 이 내 파일과 맞으면 서버 사본은 내 파일의 접두사다.
2803
+ * 그 자리에서 잇는다. **방향을 가리지 않는다**(서버가 앞서든 뒤처지든 서버 좌표에서 시작한다).
2804
+ * ⚠⚠ **좌표를 옮기기 전에 건너뛰는 원본 구간을 읽어 `cwd_hist` 에 접는다**(§5 불변식 「개인정보 판정 재료가 좌표보다
2805
+ * 늦지 않다」 · privacy B4). 안 그러면 그 구간에만 나온 사적 폴더가 판정 없이 통과한다.
2806
+ */
2807
+ const catchUp = (coords) => {
2808
+ if (!coords || String(coords.raw_key_id || '') !== keyId) return false; // 다른 기계 — MAC 을 겨룰 수 없다
2809
+ const off = Number(coords.raw_off);
2810
+ const rev = Number(coords.rev);
2811
+ if (!Number.isInteger(off) || off < 0 || off > size) return false; // 서버가 내 파일보다 뒤까지 안다 — 못 잇는다
2812
+ if (!Number.isInteger(rev) || rev < 0) return false;
2813
+ const wf = Math.max(0, off - PUSH_MAC_WIN);
2814
+ if (pushMac(key, readAt(fd, wf, off - wf)) !== String(coords.raw_win_mac || '')) return false;
2815
+ if (off > from) { const f0 = foldHist(fd, from, off, hist, lineOff); hist = f0.hist; lineOff += f0.lines; }
2816
+ // 서버가 뒤처진 쪽(off < from)은 접을 것이 없다 — 내 hist 는 이미 그 앞을 다 세었다(넉넉한 쪽으로 남긴다).
2817
+ // ⚠ 다만 `line_off` 는 되돌린다 — [off, from) 을 다시 보내며 또 세면 줄 번호가 두 번 센 값이 된다(C8 반박 fail F3 · 실측 41→62).
2818
+ // 줄 수는 빼기가 정확하다(검열 없이 \n 만 센다). cwd_hist 의 셈은 빼기가 안 되어 넉넉한 쪽으로 남는다(열쇠 집합은 같다).
2819
+ else if (off < from) lineOff = Math.max(0, lineOff - countLinesAt(fd, off, from));
2820
+ from = off;
2821
+ base = { rev, bytes: Number(coords.bytes) || 0 };
2822
+ winFrom = String(coords.raw_win_mac);
2823
+ if (!L) {
2824
+ // 대응표가 없는 따라잡기 — `head_mac` 은 서버가 안 준다. 첫 FULL 의 창 길이를 모르니 `min(64K, raw_off)` 로 **가정**한다.
2825
+ // 응답만 잃은 경우(race S8)에는 그 값이 곧 참이고, 아니면 다음 요청이 base_moved 로 돌아와 FULL 이 바로잡는다.
2826
+ headLen = Math.min(PUSH_MAC_WIN, off);
2827
+ headMac = pushMac(key, readAt(fd, 0, headLen));
2828
+ ck = off > 0 ? { off, sha: hashRange(fd, 0, off) } : null;
2829
+ }
2830
+ return true;
2831
+ };
2832
+
2833
+ /** §5.1 의 표 그대로 — 덧붙임의 거절이 어디로 가는가. **`code` 를 먼저 보고 `status` 를 나중에 본다**(관문 pre 가 413 보다 앞이다). */
2834
+ const triage = (e) => {
2835
+ const status = Number(e?.status) || 0;
2836
+ const code = String(e?.code || '');
2837
+ const why = String(e?.why || '');
2838
+ if (code === 'base_moved' || code === 'behind') return { coords: e?.coords || null };
2839
+ // 가림·걷음은 **멈춘다** — 되살리기는 사람의 전체 PUT 만(§0-5·§3.3). 입구의 붙박이 표와 같은 무게로 옮긴다.
2840
+ if (code === 'no_base') {
2841
+ return why === 'hidden' || why === 'purged'
2842
+ ? { sticky: Object.assign(new Error(e.message), { status: 409, code: 'session_hidden' }) }
2843
+ : { fall: 'no_base' };
2844
+ }
2845
+ if (code === 'rebake') return { fall: 'rebake' };
2846
+ if (code === 'base_lost') return { fall: 'base_lost' };
2847
+ if (code === 'delta_too_big' || status === 413) return { split: true };
2848
+ // 200 인데 JSON 이 아니다 — 봉투가 `post_max_size` 를 넘어 PHP 가 본문을 버렸다(운영 값 미확인 · C8 반박 fail F5).
2849
+ // 413 과 같게 줄 경계에서 더 쪼갠다. 줄 하나로도 안 되면 아래 `halve()` 가 FULL 로 내린다(그 바퀴 안에서 끝난다).
2850
+ if (e?.not_json && status === 200) return { split: true };
2851
+ if (status === 400 || status === 404 || status === 405) {
2852
+ // ★ 거절 응답이 **광고를 실었으면** 서버는 덧붙임을 안다 — 이 요청이 거절된 것이다(`bad_coords`·남의 소속처 404).
2853
+ // 광고 캐시는 그대로 두고 이 대화만 FULL 로, **예산이 세는 까닭**으로 내린다(C8 반박 fail F1 — 전에는 캐시를 거짓으로
2854
+ // 덮었지만 곧이어 도는 FULL 의 응답이 광고로 되돌려, 안 세는 FULL 이 90초마다 무기한 되풀이됐다).
2855
+ if (String(e?.caps || '').split(/[\s,]+/).includes('append1')) return { fall: 'append_rejected' };
2856
+ // 광고 없는 거절 — 이 서버(또는 그 앞의 무엇)는 덧붙임을 모른다(§5.1). 1시간 거짓(광고로도 못 덮는다)으로 두고 이번은 FULL.
2857
+ capsAppendGone();
2858
+ return { fall: 'no_cap' };
2859
+ }
2860
+ return { rethrow: true }; // 422·429·403·426·5xx·타임아웃 — 입구의 백오프가 맡는다
2861
+ };
2862
+
2863
+ const cwdFallback = isCursorBody ? relHome(cursorWorkspaceFolder() || '') : relHome(process.cwd());
2864
+
2865
+ /* ── 대응표 없음: **쓰기 없는 물음**으로 서버 좌표를 받는다(base.rev −1 · §2.10 · race S8) ───────────────── */
2866
+ if (plan.mode === 'ASK') {
2867
+ if (!srvRev) return fall('no_base'); // 이 서버의 규칙판을 아직 못 봤다 — FULL 한 번이 알려 준다
2868
+ // 판정 재료가 하나도 없으므로 [0, end) 를 통째로 접는다(셈만 · 전송 없음).
2869
+ const all = foldHist(fd, 0, end, {}, 0);
2870
+ const held0 = hooks?.check ? String(hooks.check(all.sc) || '') : '';
2871
+ if (held0) stop(held0, `자동 올리기를 보류했다(${held0})`);
2872
+ let folder0 = null;
2873
+ if (auto) {
2874
+ const prep = auto.prepare(all.sc);
2875
+ if (prep.code) throw Object.assign(new Error(`자동 올리기를 보류했다(${prep.code})`),
2876
+ { code: prep.code, status: prep.code === 'place_unreadable' ? 422 : 0 });
2877
+ folder0 = prep.folder;
2878
+ } else if (bindOf === 'propose') folder0 = pushProposeFolder(all.sc, isCursorBody);
2879
+ const Z = '0'.repeat(64);
2880
+ let coords = null;
2881
+ try {
2882
+ await api('POST', '/api/sessions/append', {
2883
+ tenant, uuid, bind: bindOf, origin_cwd: all.sc.cwd || cwdFallback, ...(folder0 ? { folder: folder0 } : {}),
2884
+ base: { rev: -1, bytes: 0 },
2885
+ raw: { key_id: keyId, from: 0, to: 0, head_mac: Z, win_mac_from: Z, win_mac_to: Z },
2886
+ scrub_rev: srvRev, delta: '',
2887
+ }, { headers, timeout_ms: apiTimeoutFor(0) });
2888
+ return fall('no_base'); // 200 은 올 수 없다(rev −1 은 언제나 base_moved) — 모르는 서버다
2889
+ } catch (e) {
2890
+ const t = triage(e);
2891
+ if (t.sticky) throw t.sticky;
2892
+ if (t.rethrow) throw e;
2893
+ if (t.coords === undefined || t.coords === null) return fall(t.fall || 'no_base');
2894
+ coords = t.coords;
2895
+ }
2896
+ if (!catchUp(coords)) return fall('no_base');
2897
+ ledgerSave(); // **전송 없이** 좌표를 되살렸다(S8)
2898
+ }
2899
+
2900
+ /* ── 조각을 모으고 보낸다 ──────────────────────────────────────────────────────────────────── */
2901
+ let moved = plan.mode === 'ASK';
2902
+ let caught = 0;
2903
+ for (;;) {
2904
+ const items = [];
2905
+ {
2906
+ let h = hist;
2907
+ let lb = lineOff;
2908
+ appendScan(fd, from, end, APPEND_RAW_STEP, APPEND_ROUND_MAX, (seg) => {
2909
+ const sc = scrubDelta(seg.buf.toString('utf8'), h, lb);
2910
+ items.push({ seg, sc, hist0: h, lb, lines: countLines(seg.buf) });
2911
+ h = sc.cwd_hist;
2912
+ lb += countLines(seg.buf);
2913
+ });
2914
+ }
2915
+ // 판정은 **이번 구간 전부를 접은 뒤** 한 번(§3.3 「cwd_hist 열쇠 전부로 매 바퀴」). 조각이 없으면 지금까지 접은 hist 로.
2916
+ const lastSc = items.length ? items[items.length - 1].sc : scrubTranscript('', { delta: true, hist });
2917
+ const held = hooks?.check ? String(hooks.check(lastSc) || '') : '';
2918
+ if (held) stop(held, `자동 올리기를 보류했다(${held})`);
2919
+ let folder = null;
2920
+ if (auto) {
2921
+ /*
2922
+ * ★ **32MB 는 「한 번 전송량」이 아니라 「서버 사본 한 벌의 크기」다**(§3.1 · C7 이 C8 로 넘긴 것).
2923
+ * 길이 FULL 뿐이던 때는 보내는 검열본 전체가 곧 그 사본이라 같은 값이었다. 덧붙임에서는 `row.bytes + 조각` 이다.
2924
+ */
2925
+ const add = items.reduce((n, it) => n + Buffer.byteLength(it.sc.jsonl, 'utf8'), 0);
2926
+ const prep = base.bytes + add > AUTO_BODY_MAX ? { code: 'too_big' } : auto.prepare(lastSc);
2927
+ if (prep.code) throw Object.assign(new Error(`자동 올리기를 보류했다(${prep.code})`),
2928
+ { code: prep.code, status: prep.code === 'place_unreadable' ? 422 : 0 });
2929
+ folder = prep.folder;
2930
+ } else if (bindOf === 'propose') folder = pushProposeFolder(lastSc, isCursorBody);
2931
+
2932
+ if (!items.length) {
2933
+ // 보낼 원본 구간이 없다 — 파일이 안 자랐거나, 따라잡기로 좌표만 옮겼다. **실패가 아니다**(입구가 간격만 비춘다).
2934
+ // ★★ 따라잡았으면 **그 좌표를 적고** 끝낸다(C8 반박 equiv ①). 전에는 보내는 고리 안의 따라잡기(409 base_moved)가 옮긴 좌표를
2935
+ // 안 적어, 다음 바퀴가 옛 좌표에서 같은 구간을 다시 검열·전송하고 409 를 받고 또 `caught_up` — 끝난 대화에서 90초마다
2936
+ // 무기한이었다(`caught_up` 은 실패가 아니라 백오프도 안 걸리고, 예산은 FULL 만 센다).
2937
+ if (moved) ledgerSave();
2938
+ throw Object.assign(new Error(moved
2939
+ ? '서버 좌표를 따라잡아 대응표만 세웠다(보낼 줄 없음)'
2940
+ : '덧붙일 줄이 없다(서버가 이미 이 대화의 끝까지 가지고 있다)'), { code: 'caught_up', status: 0, moved });
2941
+ }
2942
+
2943
+ const queue = items.slice();
2944
+ let last = null;
2945
+ let parts = 0;
2946
+ let appended = 0;
2947
+ let restart = false;
2948
+ while (queue.length) {
2949
+ const it = queue[0];
2950
+ const segTo = it.seg.to;
2951
+ const wf = Math.max(0, segTo - PUSH_MAC_WIN);
2952
+ const winTo = pushMac(key, readAt(fd, wf, segTo - wf));
2953
+ const env = {
2954
+ tenant, uuid, bind: bindOf,
2955
+ origin_cwd: it.sc.cwd || cwdFallback, ...(folder ? { folder } : {}),
2956
+ base: { rev: base.rev, bytes: base.bytes },
2957
+ raw: { key_id: keyId, from: it.seg.from, to: segTo, head_mac: headMac, win_mac_from: winFrom, win_mac_to: winTo },
2958
+ scrub_rev: srvRev, delta: it.sc.jsonl,
2959
+ ...(dryRun ? { dry_run: true } : {}),
2960
+ };
2961
+ /*
2962
+ * ★ **나누는 자는 검열본이 아니라 봉투 바이트다**(C4 가 C6→C8 로 넘긴 줄). 서버는 `strlen($delta)` 6MiB 를 재지만,
2963
+ * POST 는 그 앞에서 `post_max_size` 에 먼저 걸린다 — 넘으면 200·`text/html`·머리 0개가 와서 **조용히 아무것도
2964
+ * 안 올라간다**. 봉투를 먼저 재고, 그래도 413 이 오면 줄 경계에서 반으로 쪼개 앞쪽부터 다시 보낸다.
2965
+ */
2966
+ const envBytes = Buffer.byteLength(JSON.stringify(env), 'utf8');
2967
+ const halve = () => {
2968
+ const two = splitSeg(it.seg);
2969
+ if (!two) return false;
2970
+ queue.shift();
2971
+ const A = { seg: two[0], sc: scrubDelta(two[0].buf.toString('utf8'), it.hist0, it.lb), hist0: it.hist0, lb: it.lb, lines: countLines(two[0].buf) };
2972
+ const B = { seg: two[1], sc: scrubDelta(two[1].buf.toString('utf8'), A.sc.cwd_hist, it.lb + A.lines), hist0: A.sc.cwd_hist, lb: it.lb + A.lines, lines: countLines(two[1].buf) };
2973
+ queue.unshift(A, B);
2974
+ return true;
2975
+ };
2976
+ if (envBytes > APPEND_ENV_MAX) {
2977
+ if (halve()) continue;
2978
+ return fall('rebake'); // 줄 하나가 상한을 넘는다 — 덧붙임으로는 못 보낸다(FULL 의 조각 길로)
2979
+ }
2980
+ let r;
2981
+ try {
2982
+ r = await api('POST', '/api/sessions/append', env, { headers, timeout_ms: apiTimeoutFor(envBytes) });
2983
+ } catch (e) {
2984
+ const t = triage(e);
2985
+ if (t.split) { if (halve()) continue; return fall('rebake'); }
2986
+ if (t.sticky) throw t.sticky;
2987
+ if (t.fall) return fall(t.fall);
2988
+ if (t.rethrow) throw e;
2989
+ // base_moved / behind — 따라잡기는 **한 바퀴에 한 번**. 또 어긋나면 FULL 로 재기반한다.
2990
+ if (caught >= 1 || !catchUp(t.coords)) return fall('base_moved');
2991
+ caught++;
2992
+ moved = true;
2993
+ restart = true;
2994
+ break;
2995
+ }
2996
+ progressed();
2997
+ parts++;
2998
+ appended += Buffer.byteLength(it.sc.jsonl, 'utf8');
2999
+ last = r;
3000
+ queue.shift();
3001
+ if (dryRun) {
3002
+ /*
3003
+ * ★ **덧붙임 dry_run 도 같은 길을 간다** — 다만 조각 **하나**까지다. 서버가 행을 안 쓰므로 기반이 안 움직이고,
3004
+ * 둘째 조각의 `raw.from` 은 행의 `raw_off` 와 달라 409 base_moved 가 된다. 대응표·색인도 안 건드린다(FULL 과 같은 규칙).
3005
+ */
3006
+ return { r, before: size, parts, raw_sha: '', plan, appended, why: '' };
3007
+ }
3008
+ // 서버가 그 좌표를 세웠다고 답한 때만 대응표를 옮긴다 — `raw_win_mac === 보낸 값` 이 이 키인지도 함께 잰다(응답에 key_id 가 없다).
3009
+ if (!Number.isInteger(r?.rev) || r.raw_off !== segTo || r.raw_win_mac !== winTo) {
3010
+ ledgerDrop(uuid);
3011
+ return fall('no_base');
3012
+ }
3013
+ base = { rev: r.rev, bytes: Number.isInteger(r.bytes) ? r.bytes : base.bytes };
3014
+ from = segTo;
3015
+ winFrom = winTo;
3016
+ hist = it.sc.cwd_hist;
3017
+ lineOff = it.lb + it.lines;
3018
+ if (typeof r.scrub_rev === 'string' && CAPS_REV_RE.test(r.scrub_rev)) srvRev = r.scrub_rev;
3019
+ headInfo = { id: String(r.head_id || ''), ms: Number(r.head_ms) || 0 };
3020
+ ledgerSave(); // **성공마다** 옮긴다 — 도중에 죽어도 다음 바퀴가 여기서 잇는다
3021
+ }
3022
+ if (restart) continue;
3023
+ pushIndexNote(uuid, tenant, last, ''); // `raw_sha` 는 FULL 에서만(§2.10) — 덧붙임은 원본 전체를 안 읽는다
3024
+ return { r: last, before: size, parts, raw_sha: '', plan, appended, why: '' };
3025
+ }
3026
+ }
3027
+
2100
3028
  /**
2101
3029
  * 이 기계의 대화 하나를 검열해서 올린다.
2102
3030
  *
@@ -2115,18 +3043,86 @@ function toolsStamp() {
2115
3043
  return t;
2116
3044
  }
2117
3045
 
3046
+ /** 개행 없는 끝 줄이 **다 쓴 줄**인가 — 온전한 JSON 객체로 읽히면 그렇다(반쪽 줄은 객체로 안 읽힌다). 공백뿐이면 아니다. */
3047
+ function cursorTailDone(tail) {
3048
+ const s = tail.toString('utf8');
3049
+ if (!s.trim()) return false;
3050
+ try { const r = JSON.parse(s); return !!r && typeof r === 'object' && !Array.isArray(r); } catch { return false; }
3051
+ }
3052
+
2118
3053
  /**
2119
3054
  * @param {'propose'|'skip'} [bind] `'propose'` 면 이 대화의 자리 폴더를 그 소속처에 **제안**한다(§4.2 —
2120
3055
  * 확정·자동 올리기는 포털에서 사람이 한다). 사람이 부른 push_session 만 제안하고, 심박의 갱신은 `'skip'`.
3056
+ * @param {null|{check?: (sc: object) => string, progress?: () => void}} [hooks] 입구(C7)가 거는 손잡이 둘 —
3057
+ * `check` 는 검열 뒤·보내기 전에 한 번 불러 코드('')가 아니면 **안 보낸다**(옛 길의 사적 폴더·4096 — 봉투는 안 바꾼다:
3058
+ * `auto` 와 달리 머리·folder 를 안 싣는다) · `progress` 는 요청 하나(조각 하나)가 끝날 때마다 — 임대의 묵음 시계를 민다(§3.2).
2121
3059
  */
2122
- async function pushSession(tenant, uuid, title, dryRun, bind = 'skip', auto = null) {
3060
+ async function pushSession(tenant, uuid, title, dryRun, bind = 'skip', auto = null, hooks = null, tenantIdHint = 0) {
2123
3061
  const f = findTranscript(uuid);
2124
3062
  if (!f) throw new Error(`이 기계에 그 세션이 없습니다: ${uuid}`);
2125
- // 검열한 **바로 그 바이트**의 지문(§5.1 raw_sha) — 파일을 두 번 읽으면 그 사이에 자란 줄이 섞인다.
2126
- const bytes = fs.readFileSync(f);
2127
- const rawSha = crypto.createHash('sha256').update(bytes).digest('hex');
2128
- const sc = scrubTranscript(bytes.toString('utf8'));
3063
+ // 한 fd 로 stat 과 읽기를 한다 — 대응표의 ino·dev 가 읽은 그 파일의 것이어야 한다(§2.11).
3064
+ // ★ **덧붙임은 이 fd 를 자리 읽기로만 쓴다**(C8) — 전사를 통째로 안 읽는 것이 이 커밋의 요지다.
3065
+ const fd = fs.openSync(f, 'r');
3066
+ try { return await pushSessionFd(fd, f, tenant, uuid, title, dryRun, bind, auto, hooks, tenantIdHint); }
3067
+ finally { fs.closeSync(fd); }
3068
+ }
3069
+
3070
+ async function pushSessionFd(fd, f, tenant, uuid, title, dryRun, bind, auto, hooks, tenantIdHint) {
3071
+ const st = fs.fstatSync(fd, { bigint: true });
3072
+ const size = Number(st.size);
3073
+ const isCursorBody = f.includes(`${path.sep}.cursor${path.sep}projects${path.sep}`);
3074
+ // 끝 줄 판정도 **꼬리만** 읽는다(§2.1) — 마지막 `\n` 바로 뒤가 좌표 `raw_off` 다.
3075
+ const toEnd = lastNewlineAt(fd, size);
3076
+ const tailDone = isCursorBody && toEnd < size && cursorTailDone(readAt(fd, toEnd, size - toEnd));
3077
+ const tid = pushTenantId(tenant, uuid, tenantIdHint);
3078
+ const bindOf = bind === 'propose' ? 'propose' : 'skip';
3079
+ let headers;
3080
+ if (!auto && bindOf === 'propose') headers = { 'X-Genetics-Push': 'human' };
3081
+ if (auto) {
3082
+ headers = { 'X-Genetics-Binding': String(auto.binding_id), 'X-Genetics-Device': auto.device,
3083
+ 'X-Genetics-Client': `genetics-mcp/${VERSION}` };
3084
+ }
3085
+ /*
3086
+ * ── 길을 고른다(§2.10 · §3.2) ──────────────────────────────────────────────────────────────
3087
+ * `planPush` 은 자리 읽기만 한다. 갈래마다 FULL 의 **까닭**이 남고, 예산(`hooks.plan`)이 그 글자로 갈린다.
3088
+ * 덧붙임이 섰다가 서버가 「기반이 다르다」고 하면 **따라잡기**로 그 자리에서 잇고, 그것도 안 되면 같은 부름 안에서 FULL 로 내려온다.
3089
+ */
3090
+ const titled = typeof title === 'string' && title.trim() !== '';
3091
+ let plan = planPush(uuid, fd, st, { tenantId: tid, tailDone, titled, onServer: autoRowOnServer(indexRead()[uuid]) });
3092
+ if (plan.mode !== 'FULL') {
3093
+ const got = await pushAppend({ fd, st, uuid, tenant, tenantId: tid, dryRun, bindOf, auto, hooks, headers,
3094
+ plan, size, end: toEnd, isCursorBody });
3095
+ if (got.r) return got;
3096
+ plan = { ...plan, mode: 'FULL', why: got.why };
3097
+ }
3098
+ const held0 = hooks?.plan ? String(hooks.plan(plan) || '') : ''; // FULL 예산(§3.2) — 입구만 건다(사람의 push 는 안 센다)
3099
+ if (held0) throw Object.assign(new Error(`자동 올리기를 보류했다(${held0})`), { code: held0, status: 0 });
3100
+ const bytes = readAt(fd, 0, size);
3101
+ /*
3102
+ * ★ **마지막 `\n` 까지만 검열해 보낸다**(§2.1 · C6). 좌표 `raw_off` 가 늘 그 자리이고, 본문과 좌표가 같은 구간이어야
3103
+ * 서버 행의 끝이 곧 이 좌표다. 쓰는 중인 반쪽 끝 줄은 다음 바퀴로 넘긴다(전에는 그 줄에서 JSON 이 깨져 통째로 실패했다).
3104
+ * `raw_sha` 는 뜻 그대로 **원본 전체**의 sha 다(후보 쪽 `rawShaOf()` 와 같은 범위 — 사본 판정이 갈리지 않게).
3105
+ * 접두 [0,to) 의 sha(`ck_sha`)는 같은 해시 상태를 복사해 한 번 읽기로 낸다 — 대응표에만 적고 서버에는 안 보낸다.
3106
+ */
3107
+ const to = bytes.lastIndexOf(0x0a) + 1;
3108
+ const hasher = crypto.createHash('sha256').update(bytes.subarray(0, to));
3109
+ const ckSha = hasher.copy().digest('hex');
3110
+ const rawSha = hasher.update(bytes.subarray(to)).digest('hex');
3111
+ /*
3112
+ * ★ **Cursor 의 다 쓴 끝 줄은 싣는다 — 좌표 없이**(C6 반박 equiv). 위 끊기 규칙은 Claude Code 전사의 전제(모든 줄이 `\n` 으로
3113
+ * 끝난다 — 이 기계 148/148)다. Cursor 는 **다 끝난 대화**인데도 마지막 줄에 개행이 없는 것이 있다(173개 중 3개, 끝 줄은 전부
3114
+ * 온전한 JSON · assistant 답). 그 줄을 넘기면 파일이 안 자라는 한 영영 안 올라간다 — 색인 raw_sha 는 원본 전체라 「이미 올렸다」로
3115
+ * 보고, 사람의 push_session 도 한 줄 빠진 채 성공한다(C6 전에는 올라갔다 — 퇴행).
3116
+ * 그래서 Cursor 전사이고 끝 줄이 **온전한 JSON 객체**면 [0,끝) 을 검열해 보내되 **좌표는 안 싣는다**: 좌표 `raw_off` 는 늘 마지막
3117
+ * `\n` 바로 뒤여야 하고(§2.1), 본문과 좌표가 다른 구간이면 서버 행의 끝이 좌표가 아니다. 대응표도 지워진다(좌표 없는 FULL) —
3118
+ * 그러면 다음 갱신(C8)은 대응표가 없어 FULL 로 간다. 반쪽 끝 줄(파싱 안 됨)은 Cursor 여도 전처럼 다음 바퀴로 넘긴다.
3119
+ * Claude Code 전사는 그대로다 — 다 쓴 줄이 개행 직전에 잠깐 보이는 창에서 그 줄을 실으면 머리 끊기 규칙(transcriptHead)과 갈린다.
3120
+ */
3121
+ const sc = scrubTranscript(bytes.subarray(0, tailDone ? bytes.length : to).toString('utf8'));
2129
3122
  const before = bytes.length;
3123
+ const held = hooks?.check ? String(hooks.check(sc) || '') : '';
3124
+ if (held) throw Object.assign(new Error(`자동 올리기를 보류했다(${held})`), { code: held, status: 0 });
3125
+ const progressed = () => { try { hooks?.progress?.(); } catch { /* 임대 시계를 못 밀어도 올리기는 계속한다 */ } };
2130
3126
  /*
2131
3127
  * ★ **제목을 안 정했으면 키를 아예 뺀다.** 예전에는 `title: title || ''` 로 빈 문자열을
2132
3128
  * **명시해서** 보냈고 서버가 그것으로 기존 제목을 덮었다. 자동 올리기는 제목 없이 부르므로
@@ -2154,7 +3150,6 @@ async function pushSession(tenant, uuid, title, dryRun, bind = 'skip', auto = nu
2154
3150
  * 같은 창의 Cursor MCP 면 워크스페이스 폴더가 진짜 자리다(cursorWorkspaceFolder) —
2155
3151
  * 그것도 모르면 **빈 값**으로 둔다. 모르는 것은 모른다고 적는다(자리 판정은 빈 자리를 안다).
2156
3152
  */
2157
- const isCursorBody = f.includes(`${path.sep}.cursor${path.sep}projects${path.sep}`);
2158
3153
  const body = {
2159
3154
  tenant, uuid,
2160
3155
  origin_cwd: sc.cwd || (isCursorBody ? relHome(cursorWorkspaceFolder() || '') : relHome(process.cwd())),
@@ -2168,10 +3163,9 @@ async function pushSession(tenant, uuid, title, dryRun, bind = 'skip', auto = nu
2168
3163
  * 들어 있을 수 있어 원문을 안 보낸다. 자리는 origin_cwd 와 같은 차례로 고른다(본문 최빈 cwd →
2169
3164
  * Cursor 창 폴더 → 이 프로세스). 집·임시 폴더이거나 identity 를 못 만들면 folder 를 안 싣는다.
2170
3165
  */
2171
- body.bind = bind === 'propose' ? 'propose' : 'skip';
3166
+ body.bind = bindOf;
2172
3167
  if (body.bind === 'propose') {
2173
- const place = sc.cwd_raw || (isCursorBody ? (cursorWorkspaceFolder() || '') : process.cwd());
2174
- const folder = place ? folderForPush(folderOf(place)) : null;
3168
+ const folder = pushProposeFolder(sc, isCursorBody);
2175
3169
  if (folder) body.folder = folder;
2176
3170
  }
2177
3171
  /*
@@ -2186,17 +3180,15 @@ async function pushSession(tenant, uuid, title, dryRun, bind = 'skip', auto = nu
2186
3180
  * 하나도 없었다 — 대리 표지가 바뀌는 날 사람의 push_session 이 통째로 막힌다.
2187
3181
  * 사람의 올리기는 `push_session` 도구 하나뿐이고 그 갈래만 `bind:'propose'` 로 온다(심박의 갱신은 `'skip'`).
2188
3182
  */
2189
- let headers;
2190
- if (!auto && body.bind === 'propose') headers = { 'X-Genetics-Push': 'human' };
3183
+ // 머리 셋은 길을 고르기 전에 이미 섰다(이 함수 머리) — 덧붙임도 **같은 머리**로 간다.
2191
3184
  if (auto) {
3185
+ // 32MB 의 뜻은 **서버 사본 한 벌의 크기**다(§3.1). FULL 은 보내는 검열본 전체가 곧 그 사본이라 같은 값을 잰다.
2192
3186
  const prep = Buffer.byteLength(sc.jsonl, 'utf8') > AUTO_BODY_MAX ? { code: 'too_big' } : auto.prepare(sc);
2193
3187
  if (prep.code) {
2194
3188
  throw Object.assign(new Error(`자동 올리기를 보류했다(${prep.code})`),
2195
3189
  { code: prep.code, status: prep.code === 'place_unreadable' ? 422 : 0 }); // 서버가 같은 이유로 내는 422 와 같은 무게
2196
3190
  }
2197
3191
  body.folder = prep.folder;
2198
- headers = { 'X-Genetics-Binding': String(auto.binding_id), 'X-Genetics-Device': auto.device,
2199
- 'X-Genetics-Client': `genetics-mcp/${VERSION}` };
2200
3192
  }
2201
3193
 
2202
3194
  /*
@@ -2214,13 +3206,51 @@ async function pushSession(tenant, uuid, title, dryRun, bind = 'skip', auto = nu
2214
3206
  * 검열 뒤 43.9MB). 「먼저 dry_run」이 권장 절차라 사실상 올릴 길이 없었다. 서버가 조각
2215
3207
  * dry_run 을 받는다(조각마다 세고 파일만 안 만든다). `body.dry_run` 이 조각마다 실린다.
2216
3208
  */
3209
+ /*
3210
+ * ── 좌표(§2.9 · C6) — FULL 이 `raw:{key_id,to,head_mac,win_mac_to}`·`scrub_rev`·`base.rev`(대응표가 있으면)를 싣는다 ──
3211
+ * 조각 길이면 **마지막 조각에만** 싣는다(서버는 조각마다 모양을 재고 마지막 조각의 것만 쓴다 — 앞에 실을 까닭이 없다).
3212
+ * 옛 서버는 모르는 칸을 무시하고 전체 교체를 한다 — 뜻이 안 틀어진다. 키가 없으면 좌표 없이 옛 봉투 그대로다.
3213
+ * ⚠ **dry_run 은 키도 대응표도 안 만든다** — 서버가 행을 안 쓰는 시험 실행이 좌표 캐시를 세우면 다음 판정이 거짓 기반을 믿는다.
3214
+ */
3215
+ let coords = null;
3216
+ let coordEnv = {};
3217
+ if (!dryRun && !tailDone) { // Cursor 의 다 쓴 끝 줄을 실은 FULL 은 좌표 없이(위 tailDone)
3218
+ const key = pushKey();
3219
+ if (key) {
3220
+ coords = pushCoords(key, bytes, to);
3221
+ const L = ledgerRead(uuid);
3222
+ coordEnv = { raw: coords, scrub_rev: SCRUB_REV,
3223
+ ...(L && L.key_id === coords.key_id && Number.isInteger(L.rev) && L.rev >= 0 ? { base: { rev: L.rev } } : {}) };
3224
+ }
3225
+ }
3226
+ const ledgerCtx = { ino: String(st.ino), dev: String(st.dev), ck_sha: ckSha, cwd_hist: sc.cwd_hist,
3227
+ line_off: countLines(bytes.subarray(0, to)) };
3228
+
2217
3229
  const parts = splitAtLines(sc.jsonl, PUSH_PART_MAX);
3230
+ /*
3231
+ * ★ **FULL 도 봉투 바이트로 잰다**(C8 반박 cost①). 위 나누기는 검열본 6MiB 를 재는데, 보내는 것은 그것을 한 번 더 JSON 으로 싼
3232
+ * 봉투다 — 따옴표·역빗금이 많은 줄은 부푼다(이 기계의 전사 25개 실측 최악 1.0983배 = 6.589MiB, 한 줄 최악 1.54배). 덧붙임은
3233
+ * `APPEND_ENV_MAX` 를 지키는데 FULL 만 넘으면, `post_max_size` 가 그 사이에 있을 때 **FULL 만** 200·`text/html` 로 조용히 죽는다
3234
+ * — 배포 직후·재기반·옛 서버가 전부 FULL 로 온다. 그래서 봉투가 상한을 넘는 조각은 줄 경계에서 반씩 더 쪼갠다.
3235
+ * 조각 번호·upload_id·head_ms 칸은 **가장 긴 모양**으로 채워 잰다(한 조각 봉투는 그보다 작다 — 넉넉한 쪽).
3236
+ * 줄 하나가 넘으면 더 못 쪼갠다 — 그대로 보내고 서버의 답(413)에 맡긴다.
3237
+ */
3238
+ const envOf = (jsonl) => Buffer.byteLength(JSON.stringify({ ...body, ...coordEnv, jsonl,
3239
+ upload_id: 'f'.repeat(24), part: 99999, parts: 99999, ...(auto ? { head_ms: 9_999_999_999_999 } : {}) }), 'utf8');
3240
+ for (let i = 0; i < parts.length;) {
3241
+ if (envOf(parts[i]) <= APPEND_ENV_MAX) { i++; continue; }
3242
+ const two = splitAtLines(parts[i], Math.max(1, Math.floor(Buffer.byteLength(parts[i], 'utf8') / 2)));
3243
+ if (two.length < 2) { i++; continue; }
3244
+ parts.splice(i, 1, ...two);
3245
+ }
2218
3246
  if (parts.length === 1) {
2219
- const r = await api('PUT', '/api/sessions', body, { headers });
2220
- // `auto` — 서버가 받았으므로 서버발 막음 표(too_old·hidden·hold)를 지운다. 사람의 손 올리기가 실제로 푸는 자리다(호환 C2).
2221
- // hidden·hold 표는 **받은 소속처가 그 표의 소속처일 때만** 지운다(호환 N9 — `autoClearOnPush()`).
2222
- if (!dryRun) indexNote(uuid, { src: 'push', tenant: r.tenant || tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms, raw_sha: rawSha, auto: autoClearOnPush(r.tenant_id), ...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) }, 'tx');
2223
- return { r, before, parts: 1, raw_sha: rawSha };
3247
+ // 상한은 **보내는 바이트에 맞춘다**(호환 1-B) — 6MiB 를 120초로 못 올리는 링크가 실재한다.
3248
+ const r = await api('PUT', '/api/sessions', { ...body, ...coordEnv },
3249
+ { headers, timeout_ms: apiTimeoutFor(Buffer.byteLength(parts[0], 'utf8')) });
3250
+ progressed();
3251
+ if (!dryRun) ledgerAfterFull(uuid, r, coords, ledgerCtx);
3252
+ if (!dryRun) pushIndexNote(uuid, tenant, r, rawSha);
3253
+ return { r, before, parts: 1, raw_sha: rawSha, plan, appended: 0 };
2224
3254
  }
2225
3255
 
2226
3256
  /*
@@ -2249,14 +3279,36 @@ async function pushSession(tenant, uuid, title, dryRun, bind = 'skip', auto = nu
2249
3279
  ...body, jsonl: parts[i],
2250
3280
  upload_id: uploadId, part: i + 1, parts: parts.length,
2251
3281
  ...(auto ? { head_ms: autoHeadMs } : {}), // 관문 11 은 조각마다 선다 — 첫 조각에만 실으면 둘째부터 409 다
2252
- }, { headers });
3282
+ ...(i === parts.length - 1 ? coordEnv : {}), // 좌표는 마지막 조각에만(§2.9)
3283
+ }, { headers, timeout_ms: apiTimeoutFor(Buffer.byteLength(parts[i], 'utf8')) });
3284
+ progressed();
2253
3285
  // 바이트로 적는다 — `.length` 는 UTF-16 단위라 한글이 3배로 어긋난다(splitAtLines 의 그 함정).
2254
3286
  process.stderr.write(`genetics: 조각 ${i + 1}/${parts.length} ${dryRun ? '시험' : '올림'} `
2255
3287
  + `(${(Buffer.byteLength(parts[i], 'utf8') / 1048576).toFixed(1)}MB)\n`);
2256
3288
  }
3289
+ if (!dryRun) ledgerAfterFull(uuid, last, coords, ledgerCtx);
2257
3290
  // 〃 조각 올리기도 같다 — 마지막 조각까지 받았으면 그 uuid 에 대한 막음은 지금 안 선다(호환 C2 · 소속처 견줌은 N9).
2258
- if (!dryRun) indexNote(uuid, { src: 'push', tenant: last.tenant || tenant, tenant_id: last.tenant_id, head_id: last.head_id, head_ms: last.head_ms, raw_sha: rawSha, auto: autoClearOnPush(last.tenant_id), ...(last.tenant_slug ? { tenant_slug: last.tenant_slug } : {}) }, 'tx');
2259
- return { r: last, before, parts: parts.length, raw_sha: rawSha };
3291
+ if (!dryRun) pushIndexNote(uuid, tenant, last, rawSha);
3292
+ return { r: last, before, parts: parts.length, raw_sha: rawSha, plan, appended: 0 };
3293
+ }
3294
+
3295
+ /**
3296
+ * 성공한 올리기가 색인에 적는 것 — **한 자리**(FULL 셋과 덧붙임이 같은 줄을 지난다).
3297
+ * 서버가 받았으므로 서버발 막음 표(too_old·hidden·hold)를 지운다. 사람의 손 올리기가 실제로 푸는 자리다(호환 C2).
3298
+ * hidden·hold 표는 **받은 소속처가 그 표의 소속처일 때만** 지운다(호환 N9 — `autoClearOnPush()`).
3299
+ * ⚠ `raw_sha`(원본 전체의 sha)는 **FULL 에서만** 적는다(§2.10) — 덧붙임은 원본 전체를 안 읽으므로 그 값을 모른다.
3300
+ * 그래서 사본(dup) 판정의 비교 대상도 「색인에 적힌 지문」이 아니라 **지금 디스크의 파일**로 바뀌었다(`autoDupOf`).
3301
+ */
3302
+ function pushIndexNote(uuid, tenant, r, rawSha) {
3303
+ indexNote(uuid, { src: 'push', tenant: r.tenant || tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms,
3304
+ ...(rawSha ? { raw_sha: rawSha } : {}), auto: autoClearOnPush(r.tenant_id),
3305
+ ...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) }, 'tx');
3306
+ }
3307
+
3308
+ /** 폴더 제안(§4.2)의 자리 — 본문 최빈 cwd → Cursor 창 폴더 → 이 프로세스. FULL 과 덧붙임이 같은 자를 쓴다. */
3309
+ function pushProposeFolder(sc, isCursorBody) {
3310
+ const place = sc.cwd_raw || (isCursorBody ? (cursorWorkspaceFolder() || '') : process.cwd());
3311
+ return place ? folderForPush(folderOf(place)) : null;
2260
3312
  }
2261
3313
 
2262
3314
  /* ── 코로케이션 심박 ───────────────────────────────────────────────────
@@ -2408,25 +3460,33 @@ function indexRowSame(a, b) {
2408
3460
  /**
2409
3461
  * @param {string} uuid
2410
3462
  * @param {null|{tenant_id:number, tenant_slug?:string, tenant?:string, src?:string}} tenant 서버가 준 소속. null 이면 소속 칸을 안 건드린다
2411
- * @param {object} patch 소속 밖 칸 — 소속 칸이 섞여 오면 버린다
3463
+ * @param {object|((row: object|null) => (object|null))} patch 소속 밖 칸 — 소속 칸이 섞여 오면 버린다. **함수면 잠금 안에서**
3464
+ * 파일의 그 행을 받아 patch 를 낸다(null 이면 안 쓴다) — 입구가 실패 수를 「읽고 +1」 하는 자리(race S6 · C7)
2412
3465
  * @param {''|'tx'|'rx'} flow
2413
- * @param {null|((row: object|null) => boolean)} [guard] 잠금 안에서 파일의 그 행을 보고 거짓이면 안 쓴다(자동 올리기의 선점 — §5.5)
3466
+ * @param {null|((row: object|null) => boolean)} [guard] 잠금 안에서 파일의 그 행을 보고 거짓이면 안 쓴다(보류·막음 표의 견줌 — §5.5)
2414
3467
  */
2415
3468
  function indexWrite(uuid, tenant, patch, flow, guard = null) {
2416
3469
  if (typeof uuid !== 'string' || uuid === '' || uuid === '_v') return false;
2417
- const p = {};
2418
- for (const [k, v] of Object.entries(patch || {})) {
2419
- if (k === '_v' || INDEX_TENANT_KEYS.includes(k) || v === undefined) continue;
2420
- p[k] = v;
2421
- }
2422
3470
  const now = Date.now();
2423
- if (flow === 'tx') p.tx_at = now;
2424
- if (flow === 'rx') p.rx_at = now;
3471
+ const clean = (src) => {
3472
+ const p = {};
3473
+ for (const [k, v] of Object.entries(src || {})) {
3474
+ if (k === '_v' || INDEX_TENANT_KEYS.includes(k) || v === undefined) continue;
3475
+ p[k] = v;
3476
+ }
3477
+ if (flow === 'tx') p.tx_at = now;
3478
+ if (flow === 'rx') p.rx_at = now;
3479
+ return p;
3480
+ };
3481
+ const fixed = typeof patch === 'function' ? null : clean(patch);
2425
3482
  // (판 8 — 적합성 F7) 성공한 올리기는 **그 소속처의** 곁 표도 걷는다 — 파일의 표와 같은 견줌(`autoBlockClearedBy()`).
2426
- if (p.auto && typeof p.auto === 'object' && Object.hasOwn(p.auto, AUTO_UNBLOCK_KEY)) {
2427
- const a = autoBlockAside.get(uuid.toLowerCase());
2428
- if (a && autoBlockClearedBy(a, p.auto[AUTO_UNBLOCK_KEY])) autoBlockAside.delete(uuid.toLowerCase());
2429
- }
3483
+ const unblockAside = (p) => {
3484
+ if (p.auto && typeof p.auto === 'object' && Object.hasOwn(p.auto, AUTO_UNBLOCK_KEY)) {
3485
+ const a = autoBlockAside.get(uuid.toLowerCase());
3486
+ if (a && autoBlockClearedBy(a, p.auto[AUTO_UNBLOCK_KEY])) autoBlockAside.delete(uuid.toLowerCase());
3487
+ }
3488
+ };
3489
+ if (fixed) unblockAside(fixed);
2430
3490
  const r = jsonFileUpdate(INDEX_FILE, (cur) => {
2431
3491
  const idx = { _v: 2, ...cur };
2432
3492
  idx._v = 2; // 옛 판은 이 줄을 e?.tenant_id 로 걸러 무시한다
@@ -2435,6 +3495,13 @@ function indexWrite(uuid, tenant, patch, flow, guard = null) {
2435
3495
  // patch 만 남기면 그 행의 나머지 칸(머리·소속)을 잃고, 그 반쪽 행이 메모리 사본까지 덮는다.
2436
3496
  const was = had ? idx[uuid] : (indexMine.get(uuid) || {});
2437
3497
  if (guard && !guard(had ? idx[uuid] : null)) return null;
3498
+ let p = fixed;
3499
+ if (!p) {
3500
+ const got = patch(had ? idx[uuid] : null);
3501
+ if (!got || typeof got !== 'object') return null;
3502
+ p = clean(got);
3503
+ unblockAside(p);
3504
+ }
2438
3505
  let row = { ...was, ...p };
2439
3506
  // `auto`(백오프·보류, §5.1)는 칸끼리 합친다 — 백오프만 고치는 쓰기가 사람이 세운 hold 를 지우면 안 된다.
2440
3507
  if (p.auto && typeof p.auto === 'object') row.auto = { ...(was.auto && typeof was.auto === 'object' ? was.auto : {}), ...p.auto };
@@ -2497,24 +3564,6 @@ function indexNote(uuid, entry, flow = '') {
2497
3564
  catch { /* 색인은 있으면 좋은 것이지 도구가 실패할 이유가 아니다 */ }
2498
3565
  }
2499
3566
 
2500
- /**
2501
- * 자동 올리기의 **선점**(§5.5) — 이 기계의 창 여럿이 같은 후보를 한꺼번에 올리지 않게, 잠금 안에서 그 행의
2502
- * `auto.next_at`(다음 시도 시각)을 보고 비어 있거나 지났을 때만 `until` 로 세운다. 보류(hold)면 안 잡는다.
2503
- * 올리다 죽어도 선점은 `until` 에 저절로 풀린다 — 새 칸을 만들지 않고 백오프 칸 하나로 한다.
2504
- */
2505
- function indexClaim(uuid, until, now = Date.now()) {
2506
- let got = false;
2507
- let ok = false;
2508
- try {
2509
- ok = indexWrite(uuid, null, { auto: { next_at: until } }, '', (row) => {
2510
- const a = row?.auto;
2511
- got = !(a?.hold === true || Number(a?.next_at) > now);
2512
- return got;
2513
- });
2514
- } catch { return false; }
2515
- return got && ok;
2516
- }
2517
-
2518
3567
  /**
2519
3568
  * 사본의 **머리** — 마지막으로 남은 발화의 id·시각, 그리고 줄 수.
2520
3569
  *
@@ -2541,7 +3590,13 @@ function transcriptHead(file) {
2541
3590
  // 앞이 잘린 줄은 버린다 — 반쪽 JSON 은 파싱이 안 되지만, 버리는 것을 **의도로** 적어 둔다.
2542
3591
  // (개행이 하나도 없으면 꼬리 전체가 잘린 한 줄이다 — 남는 것이 없다.)
2543
3592
  const cut = from > 0 ? text.indexOf('\n') : -1;
2544
- const head = jsonlHead(from > 0 ? (cut < 0 ? '' : text.slice(cut + 1)) : text);
3593
+ const whole = from > 0 ? (cut < 0 ? '' : text.slice(cut + 1)) : text;
3594
+ // ★ **끊기 규칙**(§2.1 · C6): 개행 없는 끝 줄은 머리가 아니다 — 쓰는 중인 반쪽 줄이거나, 다 쓴 줄이어도 아직
3595
+ // 올릴 좌표(`raw_off` = 마지막 `\n` 바로 뒤) 밖이다. 서버가 가진 본문은 늘 `\n` 으로 끝나므로, 여기서 끝 줄을
3596
+ // 머리로 잡으면 덧붙임이 그 줄을 안 실은 채 머리만 앞서 SAME 으로 수렴하지 못한다(compat G1).
3597
+ // ⚠ jsonlHead 에 넣지 않는다 — 그 함수는 늘 `\n` 으로 끝나는 검열본(조각 봉투 head_ms)에도 쓰인다.
3598
+ const endNl = whole.lastIndexOf('\n');
3599
+ const head = jsonlHead(endNl < 0 ? '' : whole.slice(0, endNl + 1));
2545
3600
 
2546
3601
  // 줄 수 — 늘어난 만큼만 센다. 처음 보는 파일·줄어든 파일은 처음부터 세되 **파일을 통째로 올리지 않는다**
2547
3602
  // (2026-09-17: 38MB 전사를 `Buffer.alloc(size)` 로 올려 바이트 루프로 세던 자리가 부팅·압축 뒤의 버스트였다).
@@ -3289,7 +4344,6 @@ function autoPushSilence() {
3289
4344
  */
3290
4345
  process.stderr.write(`${tenantNoticeText({ state: 'bound', auto_upload: true, env_off: true })}\n`);
3291
4346
  }
3292
- const autoPushAt = new Map();
3293
4347
  /*
3294
4348
  * §5.4 「bound·auto on」(동의 표시)을 도구 꼬리로 낸 **폴더들** — 그 폴더의 전사는 그 뒤에만 올라간다.
3295
4349
  *
@@ -3334,7 +4388,10 @@ function autoConsentGiven(e) {
3334
4388
  const AUTO_SCAN_EVERY_MS = 5 * 60_000; // 되찾기 훑기는 5분에 한 번
3335
4389
  const AUTO_SCAN_MAX_AGE_MS = 30 * 24 * 3600_000; // 【선언】 mtime 30일 안
3336
4390
  const AUTO_SCAN_PER_FOLDER = 200; // 폴더당 최대 200개
3337
- const AUTO_BODY_MAX = 32 * 1024 * 1024; // 【선언】 검열본 32MB — 넘으면 안 올리고 한 번 말한다
4391
+ // 【선언】 검열본 32MB — 넘으면 안 올리고 한 번 말한다. 뜻은 **서버 사본 한 벌의 크기**다(DESIGN-session-delta-sync §3.1 · C7) —
4392
+ // 길이 FULL 뿐인 지금은 보내는 검열본 전체가 곧 그 사본이라 같은 값을 잰다. 덧붙임(C8)은 `row.bytes + 조각` 으로 잰다.
4393
+ // 확정 폴더(bound) 갈래에서만 건다 — 옛 길에는 안 씌운다(오너 결정 §0-1).
4394
+ const AUTO_BODY_MAX = 32 * 1024 * 1024;
3338
4395
  const AUTO_BACKOFF_BASE_MS = 60_000; // 실패 백오프 1분 → 2분 → 4분 …
3339
4396
  const AUTO_BACKOFF_CAP_MS = 6 * 3600_000; // … 6시간 상한
3340
4397
  const AUTO_BACKOFF_LONG_MS = 24 * 3600_000; // 403·409·422·426 → 24시간
@@ -3388,6 +4445,11 @@ function autoClearOnPush(tenantId) {
3388
4445
  * 늘 resolve 가 먼저라(§5.5) 새 서버의 답이 올리기 전에 다시 세운다. 옛 서버(409 만 말한다)에서는 프로세스마다
3389
4446
  * 한 번 보낸 뒤 선다 — 두 소속처가 같은 uuid 를 함께 막은 드문 자리의 값이다. 푸는 길은 파일의 표와 같다
3390
4447
  * (그 소속처로의 성공한 올리기 — `indexWrite()` 의 `unblock_tenant` · 그 소속처 resolve 의 `hold:false`).
4448
+ * ⚠ (C7) 설계(DESIGN-session-delta-sync §3.2)는 이 표를 「색인 쓰기가 실패할 때의 곁 표」로 읽고 걷는 목록에 넣었지만,
4449
+ * **이 표의 일은 그것이 아니다** — 색인 쓰기는 **성공한** 채로 「한 칸을 이미 다른 소속처가 쥐었다」(`'foreign'`)는 소유권의
4450
+ * 답이다. 입구가 「색인을 못 읽거나 못 쓰면 안 올린다」를 세워도 이 자리는 안 사라진다(걷으면 F7 이 되돌아온다: A 가 뺀
4451
+ * 대화가 B 의 hold:false 로 A 로 다시 간다). 그래서 **남긴다**. 걷으려면 색인 `auto.blocked` 를 소속처별로 두는 모양 바꿈이
4452
+ * 먼저다 — 그것은 옛 판이 읽는 칸의 모양이라 C7 밖의 결정이다(C8·오너로 넘김).
3391
4453
  */
3392
4454
  const autoBlockAside = new Map();
3393
4455
 
@@ -3554,8 +4616,8 @@ function autoTooOldMark(uuid, tooOld, mtime) {
3554
4616
  /**
3555
4617
  * 후보 하나를 거른다(§5.5 3) — 빈 문자열이면 올려도 된다. **순수 함수**(시험이 표로 잰다).
3556
4618
  * @param {{uuid:string, entry:?object, private_file?:boolean, row?:?object, raw_sha?:string, mtime?:number, head_ms?:number}} c
3557
- * @param {{now:number, env_on:boolean, shas?:Map<string,string>, holds?:Set<string>, holds_ok?:boolean}} ctx
3558
- * shas: 색인의 raw_sha → 그 uuid · holds: 색인 밖 보류 명부(§5.8) · holds_ok=false 면 전부 보류
4619
+ * @param {{now:number, env_on:boolean, dupOf?:(c:object)=>boolean, holds?:Set<string>, holds_ok?:boolean}} ctx
4620
+ * dupOf: 이 후보와 바이트째 같은 다른 uuid 가 있나(첫 업로드 후보에만 · C8) · holds: 색인 밖 보류 명부(§5.8) · holds_ok=false 면 전부 보류
3559
4621
  */
3560
4622
  function autoSkipReason(c, ctx) {
3561
4623
  const e = c?.entry;
@@ -3586,13 +4648,10 @@ function autoSkipReason(c, ctx) {
3586
4648
  * 답하면 푼다(`autoBlockFromResolve()` — 포털에서 가림·보류를 푼 사실이 이 기계에 닿는 길, 부록 D-30).
3587
4649
  * ⚠ 옛 서버(`hold` 칸 없음)·`none` 답으로는 안 푼다 — 그때는 사람이 한 번 올리면 된다.
3588
4650
  */
3589
- const blocked = row.auto?.blocked;
3590
- if (blocked && AUTO_STICKY_409.has(String(blocked.code))
3591
- && (!(Number(blocked.tenant_id) > 0) || Number(blocked.tenant_id) === Number(e.tenant_id))) return String(blocked.code);
3592
- // (판 8 — 적합성 F7) 파일의 표가 다른 소속처의 것이라 못 적은 막음 — 이 프로세스의 곁 표. 소속처가 같을 때만 선다.
3593
- const aside = ctx.aside?.get?.(String(c.uuid || '').toLowerCase());
3594
- if (aside && AUTO_STICKY_409.has(String(aside.code))
3595
- && Number(aside.tenant_id) > 0 && Number(aside.tenant_id) === Number(e.tenant_id)) return String(aside.code);
4651
+ // (판 8 — 적합성 F7) 파일의 표가 다른 소속처의 것이라 못 적은 막음은 이 프로세스의 곁 표(ctx.aside) — 소속처가 같을 때만 선다.
4652
+ // 판정은 옛 길의 입구와 **같은 한 벌**(`autoBlockedFor()` · C7)이다.
4653
+ const blocked = autoBlockedFor(row, e.tenant_id, ctx.aside?.get?.(String(c.uuid || '').toLowerCase()));
4654
+ if (blocked) return blocked;
3596
4655
  /*
3597
4656
  * 동의 앞의 대화는 안 올린다(D3) — 잰 자리가 없는 후보(순수 시험의 표)는 이 줄을 안 탄다.
3598
4657
  * ① mtime 은 **싼 앞거르개**다: 파일이 하한보다 이르면 마지막 발화도 대개 그보다 이르다
@@ -3609,7 +4668,41 @@ function autoSkipReason(c, ctx) {
3609
4668
  */
3610
4669
  const tooOld = Number(row.auto?.too_old) || 0;
3611
4670
  if (tooOld > 0 && !(Number(c.head_ms) > 0 && Number(c.head_ms) >= tooOld)) return 'too_old';
3612
- if (c.raw_sha && ctx.shas?.has(c.raw_sha) && ctx.shas.get(c.raw_sha) !== c.uuid) return 'dup';
4671
+ /*
4672
+ * ★ **사본(dup) 은 첫 업로드 후보에만 건다 — 그리고 비교 대상은 「지금 디스크의 파일」이다**(§2.10 · cost G1 · C8).
4673
+ * 전에는 색인의 `raw_sha`(마지막으로 올린 때의 지문)끼리 견줬다. 덧붙임이 서면 그 칸은 **마지막 FULL 때의 값**이라
4674
+ * 자란 파일을 말하지 않는다 — 상대 쪽 지문이 낡아 거르개가 그냥 뚫린다(실패가 서버 계보로 옮겨 갈 뿐이다).
4675
+ * 그리고 이미 올라간 대화가 자라는 것은 사본이 아니라 제 자리로 자라는 것이다.
4676
+ */
4677
+ if (c.first && typeof ctx.dupOf === 'function' && ctx.dupOf(c)) return 'dup';
4678
+ return '';
4679
+ }
4680
+
4681
+ /**
4682
+ * 사본(dup) 판정 — 이 후보와 **바이트째 같은** 다른 uuid 가 이 기계에 있나(§2.10). 없으면 ''.
4683
+ * 한 번 이상 올라간 다른 uuid 의 전사 가운데 **크기가 같은 것**만 해시해 견준다 — 평소 비용은 stat 뿐이다.
4684
+ * `rawShaOf` 는 (크기, mtime) 로 기억하므로 같은 파일을 두 번 안 읽는다.
4685
+ */
4686
+ function autoDupOf(uuid, file, idx) {
4687
+ let size = -1;
4688
+ try { size = fs.statSync(file).size; } catch { return ''; }
4689
+ if (!(size > 0)) return '';
4690
+ const same = [];
4691
+ for (const [u, row] of Object.entries(idx || {})) {
4692
+ if (u === uuid || !AUTO_UUID_RE.test(u) || !row || typeof row !== 'object') continue;
4693
+ // 한 번도 안 올라간 행은 견줄 대상이 아니다 — 「이미 올라간 다른 대화의 사본인가」가 이 판정의 뜻이다.
4694
+ if (!autoRowOnServer(row) && !(typeof row.raw_sha === 'string' && row.raw_sha !== '')) continue;
4695
+ const f = transcriptOf(u);
4696
+ if (!f || f === file) continue;
4697
+ let s;
4698
+ try { s = fs.statSync(f); } catch { continue; }
4699
+ if (s.size !== size) continue;
4700
+ same.push({ uuid: u, file: f });
4701
+ }
4702
+ if (!same.length) return '';
4703
+ const my = rawShaOf(file);
4704
+ if (!my) return '';
4705
+ for (const m of same) if (rawShaOf(m.file) === my) return m.uuid;
3613
4706
  return '';
3614
4707
  }
3615
4708
 
@@ -3717,15 +4810,19 @@ function autoCandidates(now = Date.now()) {
3717
4810
  return out;
3718
4811
  }
3719
4812
 
4813
+ /**
4814
+ * 한 바퀴의 **잠금 밖 스냅숏** — 심박 **앞에서** 한 번 뜬다(beatTick). 거르개가 싸게 먼저 걸러 헛된 임대 잡기를 줄이는 데만 쓴다.
4815
+ * ⚠ 보증이 아니다: 입구(`autoEntrance()`)가 임대 안에서 색인을 **다시** 읽어 같은 판정을 한 번 더 한다(race M4 · C7).
4816
+ * `idx` 는 그때의 색인 사본 — 못 읽었으면 `{}` 다(여기서는 그래도 된다 — 입구가 못 읽음을 따로 가른다).
4817
+ */
3720
4818
  function autoContext(now = Date.now()) {
3721
- const shas = new Map();
3722
- for (const [u, row] of Object.entries(indexRead())) {
3723
- if (AUTO_UUID_RE.test(u) && row && typeof row.raw_sha === 'string' && row.raw_sha !== '' && !shas.has(row.raw_sha)) shas.set(row.raw_sha, u);
3724
- }
4819
+ const idx = indexRead();
4820
+ // 사본 거르개는 **부를 때** 잰다(첫 업로드 후보에만) — 바퀴마다 모든 행의 지문을 미리 짓지 않는다(§2.10 · cost G1).
4821
+ const dupOf = (c) => !!autoDupOf(c.uuid, c.file, idx);
3725
4822
  const h = autoHoldsRead(); // 색인 밖 보류 명부 — 못 읽으면 이 바퀴는 아무것도 안 올린다(H2)
3726
4823
  // 닫는 쪽으로 실패하되 **조용히는 아니다** — 이 상태가 이어지면 자동 올리기가 영영 안 돈다. 사람이 고칠 수 있게 한 번 말한다.
3727
4824
  if (!h.ok) warnOnce('auto-holds-unreadable', `${HOLDS_FILE} 를 못 읽어 이 기계의 자동 올리기를 전부 멈춘다(무엇이 보류인지 모르므로). 그 파일을 고치거나 지우면 다시 돈다.`);
3728
- return { now, env_on: autoPushOn(), shas, holds: h.set, holds_ok: h.ok, aside: autoBlockAside };
4825
+ return { now, idx, env_on: autoPushOn(), dupOf, holds: h.set, holds_ok: h.ok, aside: autoBlockAside };
3729
4826
  }
3730
4827
 
3731
4828
  /** 서버 최소 판보다 낡았는가 — 그러면 자동 올리기를 안 두드린다(서버가 426 으로 막을 자리다, §4.3 관문 2). */
@@ -3790,13 +4887,18 @@ function autoBeatRecs(cands, ctx) {
3790
4887
  /**
3791
4888
  * 이 대화가 거쳐 간 자리 **전부**에 `.genetics-private` 이 있나(개인정보 검토 m1).
3792
4889
  * 최빈 자리 하나만 보면, 세션의 일부가 사적인 하위 폴더에서 돌아도 그 대화가 통째로 올라간다.
4890
+ * ★ 재료는 `cwd_hist` 의 **열쇠 전부**다(§2.2 · C6) — 예전 재료(`cwds_raw`)는 빈도순 상위 64 로 잘려서, 한 번만 들른
4891
+ * 사적 폴더(65위 아래)가 판정에서 빠졌다. `cwd_hist` 가 없는 입력(옛 모양)만 `cwds_raw` 로 물러선다.
3793
4892
  */
3794
4893
  function autoPrivateAnyCwd(sc, stopReal) {
3795
- for (const raw of (Array.isArray(sc.cwds_raw) ? sc.cwds_raw : [])) {
4894
+ const places = sc?.cwd_hist && typeof sc.cwd_hist === 'object' ? Object.keys(sc.cwd_hist)
4895
+ : (Array.isArray(sc?.cwds_raw) ? sc.cwds_raw : []);
4896
+ for (const raw of places) {
3796
4897
  let r = String(raw || '');
3797
4898
  if (!r) continue;
3798
4899
  try { r = fs.realpathSync(r); } catch { /* 없는 자리면 준 값 그대로 */ }
3799
- if (privateFileFor(r, stopReal)) return true;
4900
+ // 멈출 자리는 한 곳(확정 폴더의 뿌리)이거나, 자리마다 정하는 함수(옛 길 — 그 자리의 git 뿌리, C7)다.
4901
+ if (privateFileFor(r, typeof stopReal === 'function' ? stopReal(r) : stopReal)) return true;
3800
4902
  }
3801
4903
  return false;
3802
4904
  }
@@ -3804,6 +4906,8 @@ function autoPrivateAnyCwd(sc, stopReal) {
3804
4906
  /** 보내기 직전 — 본문의 자리(최빈 cwd, realpath)가 그 확정 폴더 안인가. 맞으면 서버 관문 5·6 이 대조할 `folder` 를 낸다. */
3805
4907
  function autoPrepare(sc, c) {
3806
4908
  const e = c.entry;
4909
+ // 거쳐 간 자리가 너무 많으면 private 판정을 다 못 잰다 — 자동 길은 **닫힌 쪽**으로 멈춘다(§2.2). 사람의 push 는 이 함수를 안 지난다.
4910
+ if (autoHistOverflow(sc)) return { code: 'cwd_hist_overflow' };
3807
4911
  let real = String(sc.cwd_raw || '');
3808
4912
  if (!real) return { code: 'place_unreadable' };
3809
4913
  try { real = fs.realpathSync(real); } catch { /* 없는 자리면 준 값 그대로 */ }
@@ -3821,122 +4925,522 @@ function autoPrepare(sc, c) {
3821
4925
  return { folder: { kind: 'path', identity: id, subpath: '', path: bound } };
3822
4926
  }
3823
4927
 
3824
- /** 후보 하나를 올린다 — 머리 셋을 붙인 PUT(§4.3). 결과를 색인 `auto` 에 적는다(성공은 비우고, 실패는 물러선다). */
3825
- async function autoUploadOne(c) {
3826
- const e = c.entry;
3827
- const u8 = c.uuid.slice(0, 8);
4928
+ /* ── 한 입구 (DESIGN-session-delta-sync §3 · C7) ─────────────────────────────────────────────────────
4929
+ *
4930
+ * 무엇을 푸는가: 이 기계에는 MCP 창이 열 개 넘게 돈다. 전에는 자동 올리기가 두 길(① 확정 폴더 · ② 옛 길)로 갈라져 있었고,
4931
+ * 「한 대화는 기계에서 한 번만 올린다」가 ① 에만, 그것도 **보내는 동안만** 섰다(`indexClaim` 선점 → 성공하면 `next_at:0` 으로
4932
+ * 되돌림 — 「방금 보냈다」를 아무도 못 봤다). ② 는 프로세스 메모리(`autoPushAt`)뿐이라 창 N 개가 같은 대화를 N 번 보냈다
4933
+ * (2026-09-21 실측: 5분 PUT 514회 중 68% 가 같은 머리).
4934
+ * 이제 **모든 자동 올리기가 이 입구 하나**를 지난다: 기계 공용 임대(`push/<uuid>.lease`, 기존 `fileLock` 원시) → 임대 안에서
4935
+ * 색인을 **다시 읽고** 모든 판정(간격·백오프·막음·닫힘·FULL 예산) → `pushSession`(사람과 같은 함수) → 결과를 잠금 안 mutate 로
4936
+ * → finally 에서 토큰으로 푼다. 판정을 임대 **밖**에서 하면 두 창이 모두 통과한 뒤 차례로 올린다(race M4) — 순서가 보증이다.
4937
+ * 정책 상태는 새 저장소가 아니라 지금 기계 공용인 색인 `auto.*` 에 둔다(`last_at` 칸 하나를 더했다).
4938
+ */
4939
+ /**
4940
+ * 【선언】 임대의 묵음 한도 — **가장 큰 조각의 요청 상한** + 60초. 진전이 이만큼 없으면 남이 걷는다(§3.2 · race S1).
4941
+ * 상한이 바이트에 비례하므로(`apiTimeoutFor`) 여기서도 그 최대값을 쓴다 — 한 곳을 고치면 둘이 함께 움직인다(호환 1-B).
4942
+ * ⚠ 가장 큰 요청은 이제 **덧붙임 봉투**(`APPEND_ENV_MAX` 6.5MiB)다 — FULL 조각(검열본 6MiB)보다 크다. 둘 중 큰 쪽으로 잰다
4943
+ * (C8 반박 fail F4 — 전에는 선언한 「+60초」가 실제로 +44초였다).
4944
+ */
4945
+ const LEASE_QUIET_MS = apiTimeoutFor(Math.max(PUSH_PART_MAX, APPEND_ENV_MAX)) + 60_000;
4946
+ /** 【선언】 사람의 push_session 이 임대를 기다리는 상한(§3.4) — 비동기로만(Atomics.wait 금지 · race S5). */
4947
+ const HUMAN_LEASE_WAIT_MS = 30_000;
4948
+ const HUMAN_LEASE_POLL_MS = 250;
4949
+ /*
4950
+ * 【선언】 **FULL 예산**(§5 불변식 「전체 재전송으로 폭주하지 않는다」 · C7 이 C8 로 미룬 것). C7 에 못 둔 까닭 둘을 이 커밋이 먼저 풀었다:
4951
+ * ① 재료가 안 쌓였다 — `full_log` 가 **대응표** 안에 있어서, 서버가 좌표를 안 돌려주면 `ledgerAfterFull()` 이 대응표를 통째로
4952
+ * 지우며 기록도 함께 갔다. → 기록을 **색인 `auto.full_log`** 로 옮겼다(정책 상태의 자리 · §2.11 「정책 상태는 대응표에 두지 않는다」).
4953
+ * ② 사람의 push 를 안 가렸다 — `ledgerAfterFull` 은 누가 불렀는지 모른다. → 기록하는 자를 **입구 하나**(`autoEntrance`)로 좁혔다.
4954
+ * 사람의 `push_session` 은 예산을 **세지도 않고 받지도 않는다**(`hooks.plan` 을 안 건다).
4955
+ * 그리고 뜻이 이제 선다: 길이 APPEND 라 예산은 **진짜 FULL(재기반)** 에만 걸린다(`PUSH_FULL_REBASE`) — 첫 업로드도, 옛 서버의
4956
+ * FULL 도 안 센다. 넘으면 **조용히 버리지 않는다**: 색인에 `last_code:'full_budget'` 을 남기고 `next_at` 을 한 자리가 비는 시각으로 민다.
4957
+ */
4958
+ /*
4959
+ * 【선언】 **닫힌 쪽으로 멈추는 보류**(C6 넘김 · C7 결정): 사적 폴더(`private`)와 자리 4096 초과(`cwd_hist_overflow`).
4960
+ * 둘 다 서버가 아니라 이 기계가 본문을 재서 내리는 판정이라, **파일이 그대로면 답도 그대로다**(cwd_hist 열쇠는 덧붙이기만으로는
4961
+ * 줄지 않고, 표지 파일이 걷혀도 판정 재료는 같은 파일이다) — 전에는 일반 백오프(1분 → … 6시간)를 타서 그때마다 전사 전체를 다시
4962
+ * 읽고 검열했다. 이제 그때의 크기·mtime 을 `auto.closed` 에 적고 **파일이 바뀔 때까지 다시 안 본다**. 바뀌면(자람·교체) 백오프가
4963
+ * 지난 뒤 한 번 다시 잰다 — 사람이 표지를 걷었거나 전사가 갈렸을 수 있다. 판정은 크기·mtime `!==` 다(시계가 되돌아간 것도 「바뀜」).
4964
+ */
4965
+ const AUTO_CLOSED_CODES = new Set(['private', 'cwd_hist_overflow']);
4966
+
4967
+ function leaseFile(uuid) {
4968
+ const f = ledgerFile(uuid); // 같은 uuid 모양 검사 · 같은 자리(push/) — 대응표 옆
4969
+ return f ? `${f.slice(0, -'.json'.length)}.lease` : '';
4970
+ }
4971
+
4972
+ /**
4973
+ * 임대를 **한 번만** 잡아 본다(비차단). `{h}` 잡음 · `{held:true}` 남이 쥐었다 · `{err}` 자리를 못 만들었다.
4974
+ * ⚠ `fileLock` 은 EEXIST 가 아닌 실패(자리 없음 등)도 null 이라, 자리를 먼저 만들고 null 이면 파일이 있는지로 가른다 —
4975
+ * 안 그러면 못 만든 자리가 「남이 올리는 중」으로 읽힌다.
4976
+ */
4977
+ function leaseTake(uuid) {
4978
+ const lock = leaseFile(uuid);
4979
+ if (!lock) return { err: 'uuid' };
4980
+ try { fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 }); } catch (e) { return { err: String(e?.code || 'mkdir') }; }
4981
+ // SIGKILL 로 죽은 창이 남긴 `<lease>.stale-*` — 임대는 `jsonFileUpdate` 를 안 지나 아무도 안 치웠다(반박 race·compat 2-D).
4982
+ sweepLeftovers(lock);
4983
+ const h = fileLock(lock, Date.now(), { stale: LEASE_QUIET_MS, lock });
4984
+ if (h) return { h };
4985
+ // ⚠ **못 읽는 임대를 「남이 쥠」으로 읽지 않는다**(반박 race). EACCES 면 `lockStaleText` 도 실패해 영영 조용히 안 올린다 —
4986
+ // 까닭을 내면 입구가 `warnOnce` 로 말한다.
4987
+ try { fs.readFileSync(lock, 'utf8'); } catch (e) { if (e?.code !== 'ENOENT') return { err: String(e?.code || 'read') }; }
4988
+ return fs.existsSync(lock) ? { held: true } : { err: 'open' };
4989
+ }
4990
+
4991
+ /** 진전(요청 하나가 끝남)이 있을 때만 임대의 mtime 을 민다 — 타이머로는 안 민다(race S1). 내 토큰일 때만. */
4992
+ function leaseTouch(h) {
3828
4993
  try {
3829
- const { r } = await pushSession(e.tenant_slug || e.tenant, c.uuid, '', false, 'skip',
3830
- { binding_id: Number(e.binding_id) || 0, device: deviceId(), prepare: (sc) => autoPrepare(sc, c) });
3831
- indexPatch(c.uuid, { auto: { fails: 0, next_at: 0, last_code: '', ...autoClearOnPush(Number(r?.tenant_id) || e.tenant_id) } });
3832
- process.stderr.write(`genetics: ${u8} 를 확정 폴더의 자동 올리기로 올렸다 — «${r.tenant || e.tenant}» · ${r.lines}줄.\n`);
3833
- } catch (err) {
3834
- const status = Number(err?.status) || 0;
3835
- const code = String(err?.code || (status ? `http_${status}` : 'error'));
3836
- const fails = (Number(indexRead()[c.uuid]?.auto?.fails) || 0) + 1;
4994
+ if (fs.readFileSync(h.lock, 'utf8') !== h.token) return;
4995
+ const t = new Date();
4996
+ fs.utimesSync(h.lock, t, t);
4997
+ } catch { /* 없어졌으면 민 것이 없다 */ }
4998
+ }
4999
+
5000
+ /** 사람의 push_session 이 임대를 기다린다 — **비동기**(setTimeout)로만. 못 잡으면 null · 자리를 못 만들면 `{err}` 그대로. */
5001
+ async function leaseWait(uuid, ms = HUMAN_LEASE_WAIT_MS) {
5002
+ const until = Date.now() + ms;
5003
+ for (;;) {
5004
+ const got = leaseTake(uuid);
5005
+ if (!got.held) return got;
5006
+ if (Date.now() >= until) return got;
5007
+ await new Promise((r) => setTimeout(r, HUMAN_LEASE_POLL_MS));
5008
+ }
5009
+ }
5010
+
5011
+ /**
5012
+ * 간격 — 이 기계의 누군가가 이 대화를 `AUTO_PUSH_MIN_MS` 안에 올렸나. **순수 함수.**
5013
+ * 시계가 되돌아가 그 시각이 한참 미래면(간격보다 멀리) 그 값은 안 믿는다 — 그렇지 않으면 되돌린 만큼 멎는다.
5014
+ *
5015
+ * ★★ 재료가 **둘**이다(반박 compat 1-A): 새 판이 적는 `auto.last_at`, 그리고 **성공한 올리기가 언제나 적는 `tx_at`**
5016
+ * (`indexWrite(..., 'tx')` — 사람의 push 도, **옛 판의 두 길도** 적는다). 옛 판 ①은 성공하면 `next_at` 을 0 으로
5017
+ * 되돌리고 `last_at` 을 안 적어, `last_at` 만 보면 「옛 창이 방금 올렸다」가 색인 어디에도 안 남는다 — 그 순간
5018
+ * AHEAD 판정을 쥔 새 창이 그대로 또 보냈다(실측: 옛 판이 먼저 올리는 혼합 함대에서 2회). 이 기계의 MCP 는 창마다
5019
+ * 오래 살아 **혼합이 평상 상태**라, 이 한 칸이 「기계에서 uuid 마다 간격당 1회」를 옛 판 쪽에서도 세운다.
5020
+ */
5021
+ function autoIntervalOf(row, now) {
5022
+ const at = Math.max(Number(row?.auto?.last_at) || 0, Number(row?.tx_at) || 0);
5023
+ return at > 0 && Math.abs(now - at) < AUTO_PUSH_MIN_MS;
5024
+ }
5025
+
5026
+ /**
5027
+ * 입구의 나머지 판정(보류·백오프·닫힘) — **순수 함수**. '' 이면 보낸다. (FULL 예산은 C8 — 위 【없음】 주석)
5028
+ * @param {object|null} row 색인의 그 행
5029
+ * @param {{now:number, size:number, mtime:number}} f 파일의 지금
5030
+ */
5031
+ function autoGateOf(row, f) {
5032
+ const a = row?.auto && typeof row.auto === 'object' ? row.auto : {};
5033
+ if (a.hold === true) return 'hold';
5034
+ // 시계 가드는 간격과 **같은 규율**이다(반박 race) — 시계가 앞으로 뛴 뒤 적힌 `next_at` 은 백오프의 최대값(24시간)보다
5035
+ // 멀 수 없으므로, 그보다 먼 미래면 안 믿는다. 한쪽만 방어하면 그 대화가 그 시각까지 통째로 멎는다.
5036
+ const next = Number(a.next_at) || 0;
5037
+ if (next > f.now && next - f.now <= AUTO_BACKOFF_LONG_MS) return 'backoff';
5038
+ const c = a.closed;
5039
+ if (c && typeof c === 'object' && AUTO_CLOSED_CODES.has(String(c.code))
5040
+ && Number(c.size) === Number(f.size) && Number(c.mtime) === Number(f.mtime)) return String(c.code);
5041
+ return '';
5042
+ }
5043
+
5044
+ /**
5045
+ * FULL 예산이 남았나 — **순수 함수**. 0 이면 남았다, 아니면 **한 자리가 비는 시각**(epoch ms)이다.
5046
+ * 재료는 색인 `auto.full_log`(최근 `PUSH_FULL_LOG_MAX` 개의 **진짜 FULL** 시각). 시계 가드는 간격·백오프와 같은 규율이다 —
5047
+ * 한참 미래의 기록은 안 믿는다(시계가 앞으로 뛴 뒤 적힌 값 하나가 그 대화를 영영 묶지 않게).
5048
+ * @param {object|null} row 색인의 그 행
5049
+ */
5050
+ function autoFullBudgetOf(row, now) {
5051
+ const log = (Array.isArray(row?.auto?.full_log) ? row.auto.full_log : [])
5052
+ .map(Number).filter((t) => Number.isFinite(t) && t > 0 && now - t < PUSH_FULL_WINDOW_MS && t - now < PUSH_FULL_WINDOW_MS);
5053
+ if (log.length < PUSH_FULL_BUDGET) return 0;
5054
+ return Math.min(...log) + PUSH_FULL_WINDOW_MS;
5055
+ }
5056
+
5057
+ /** 이번 바퀴가 **예산이 세는 FULL** 인가 — 재기반이고, 첫 업로드가 아니다(§3.2 · C8). */
5058
+ function autoFullCounts(plan, verdict) {
5059
+ return plan?.mode === 'FULL' && PUSH_FULL_REBASE.has(String(plan.why || ''))
5060
+ && verdict !== BEAT_VERDICT.UNKNOWN;
5061
+ }
5062
+
5063
+ /**
5064
+ * 그 행에 **그 소속처**에 대한 붙박이 막음(가림·보류 409)이 서 있나 — 코드 또는 ''. **순수 함수**(개인정보 NEW-4 · 판 8 F7).
5065
+ * 파일의 표는 소속처를 안 적은 옛 표이거나 같은 소속처일 때, 곁 표(`aside`)는 같은 소속처일 때만 선다.
5066
+ * `autoSkipReason()`(확정 폴더)과 옛 길의 입구(C7 · 오너 결정 §0-5)가 이 한 벌을 쓴다.
5067
+ */
5068
+ function autoBlockedFor(row, tenantId, aside) {
5069
+ const tid = Number(tenantId) || 0;
5070
+ const blocked = row?.auto?.blocked;
5071
+ if (blocked && AUTO_STICKY_409.has(String(blocked.code))
5072
+ && (!(Number(blocked.tenant_id) > 0) || Number(blocked.tenant_id) === tid)) return String(blocked.code);
5073
+ if (aside && AUTO_STICKY_409.has(String(aside.code))
5074
+ && Number(aside.tenant_id) > 0 && Number(aside.tenant_id) === tid) return String(aside.code);
5075
+ return '';
5076
+ }
5077
+
5078
+ /** 거쳐 간 자리가 `CWD_HIST_MAX` 를 넘나 — 넘으면 private 판정을 다 못 잰다(§2.2). 확정 폴더(autoPrepare)와 옛 길이 같은 자를 쓴다. */
5079
+ function autoHistOverflow(sc) {
5080
+ return !!(sc?.cwd_hist && typeof sc.cwd_hist === 'object' && Object.keys(sc.cwd_hist).length > CWD_HIST_MAX);
5081
+ }
5082
+
5083
+ /**
5084
+ * 그 자리의 git 뿌리(`.git` 이 있는 가장 가까운 위 폴더) — 집·뿌리에서 멈춘다. 없으면 ''. git 을 부르지 않는다(자리가 수천일 수 있다).
5085
+ * 짝인 `privateFileFor` 와 **같은 규율로 60초 기억한다** — cwd_hist 열쇠가 4096 곳이면 한 번의 옛 길 검사에 `existsSync` 가
5086
+ * 수만 번이다(반박 policy).
5087
+ */
5088
+ const gitRootSeen = new Map(); // real → { at, root }
5089
+ function gitRootAbove(real) {
5090
+ const key = String(real || '');
5091
+ const c = gitRootSeen.get(key);
5092
+ if (c && Date.now() - c.at < PRIVATE_TTL_MS) return c.root;
5093
+ let h = os.homedir();
5094
+ try { h = fs.realpathSync(h); } catch { /* */ }
5095
+ let d = key;
5096
+ let root = '';
5097
+ for (let i = 0; i < 64 && d && d !== '/' && d !== h; i++) {
5098
+ if (fs.existsSync(path.join(d, '.git'))) { root = d; break; }
5099
+ const up = path.dirname(d);
5100
+ if (up === d) break;
5101
+ d = up;
5102
+ }
5103
+ if (gitRootSeen.size > 8192) gitRootSeen.clear();
5104
+ gitRootSeen.set(key, { at: Date.now(), root });
5105
+ return root;
5106
+ }
5107
+
5108
+ /**
5109
+ * 옛 길의 보내기 전 검사(오너 결정 2026-09-21 · §0-1 의 private) — 자리 4096 초과면 `cwd_hist_overflow`, 거쳐 간 자리
5110
+ * **전부**(cwd_hist 열쇠) 중 하나라도 `.genetics-private` 아래면 `private`. 확정 바인딩이 없으니 멈출 자리는 **열쇠마다 그 git 뿌리,
5111
+ * 없으면 집**이다 — 비워 두면(그 자리 하나) git 저장소가 아닌 자리에서 **상위 폴더의 표지를 통째로 놓친다**(반박 policy 실측:
5112
+ * `~/secret/.genetics-private` + cwd `~/secret/work` 가 안 막혔다. 사람이 표지를 두는 가장 흔한 무늬다). `privateFileFor` 는
5113
+ * 집과 `/` 에서 멈추므로 집 밖 자리(예: /tmp)는 지금처럼 그 한 칸만 본다. 32MB·동의 문구·too_old·pull 은 **안 씌운다**(오너 결정).
5114
+ * Cursor 전사는 줄에 자리가 없어 이 검사가 볼 것이 없다(지금처럼 올라간다).
5115
+ */
5116
+ function autoLegacyCheck(sc) {
5117
+ if (autoHistOverflow(sc)) return 'cwd_hist_overflow';
5118
+ // ⚠ 집은 **realpath 로** 준다 — `privateFileFor` 는 「자리가 멈출 자리 아래인가」를 글자로 재는데, macOS 의 `/var` 처럼
5119
+ // 집이 심링크 뒤에 있으면(`$HOME=/var/…` · 자리는 `/private/var/…`) 안 걸려 멈출 자리가 그 한 칸으로 되돌아간다(실측).
5120
+ let home = os.homedir();
5121
+ try { home = fs.realpathSync(home); } catch { /* 없는 집이면 준 값 그대로 */ }
5122
+ if (autoPrivateAnyCwd(sc, (r) => gitRootAbove(r) || home)) return 'private';
5123
+ return '';
5124
+ }
5125
+
5126
+ /**
5127
+ * 후보 하나의 갈래 — `'bound'` · `'legacy'` · `null`. **세 갈래를 명시한다 — 여집합으로 짜지 않는다**(§3.1 · privacy B5).
5128
+ * 판정 재료는 **그 전사 폴더의 resolve 캐시 답**(`t.entry`)이다 — `autoCandidates()` 에 들었는지가 아니다(그 목록은 pull 행·Cursor 를
5129
+ * 뺀다). 거르개는 잠금 밖 스냅숏(`ctx` — 심박 앞에서 뜬 것)으로 먼저 싸게 거르는 것이고, 입구가 임대 안에서 **다시** 본다.
5130
+ * 안 가는 까닭은 `t.why` 에 적는다(시험이 읽는다).
5131
+ *
5132
+ * · `bound` — 확정 바인딩 **그리고** 자동 켜짐 **그리고** 안 낡음 **그리고** 후보 목록 안. 거르개는 ① 그대로: 판 낡음
5133
+ * (`autoClientOld` 는 이 갈래만 끈다) · 옮겨짐 · 판정 UNKNOWN(첫 업로드)/AHEAD · 서버를 거친 행의 UNKNOWN
5134
+ * (가렸거나 걷음) · 동의 문구 · 429 대기 · too_old 채움 → `autoSkipReason()` → 머리·사본 → 다시
5135
+ * `autoSkipReason()` · 간격·백오프·닫힘.
5136
+ * · null ① — 확정 바인딩 **그리고** (꺼짐 | 낡음 | ambiguous). 오너가 끈 동의를 옛 길이 거스르지 않는다.
5137
+ * · `legacy` — 확정 바인딩 **없음**(답이 없거나 `unbound`), **또는 확정·자동 폴더인데 후보 목록 밖**(pull 행 — 아래 ★) ·
5138
+ * 판정 AHEAD **그리고** WANT_PUSH(서버가 올려 달라고 할 때만) ·
5139
+ * 색인에 소속(`_tenant_arg`) · 보류 명부를 읽을 수 있고 그 안에 없음 · `auto.hold` 아님 · 그 소속처의 붙박이 막음
5140
+ * 없음(§0-5) · 간격·백오프·닫힘. ①의 정책 거르개(32MB·동의·too_old·pull)는 **안 씌운다**(§0-1). 사적 폴더·4096 은
5141
+ * 보내기 직전(`autoLegacyCheck`)에 본다.
5142
+ * · null ② — 그 밖 전부(모르는 폴더 상태 포함).
5143
+ * @param {{uuid:string, v:?object, c:?object, entry:?object, row:?object, rec:?object, size:number, mtime:number}} t
5144
+ * @param {{now:number, holds?:Set<string>, holds_ok?:boolean, aside?:Map, client_old?:boolean,
5145
+ * consent?:(e:object)=>boolean, tenant_wait?:Map<number,number>, dupOf?:(c:object)=>boolean, env_on?:boolean}} ctx
5146
+ */
5147
+ function autoPolicyOf(t, ctx) {
5148
+ const no = (why) => { t.why = why; return null; };
5149
+ const v = t.v;
5150
+ if (!v) return no('no_verdict');
5151
+ const e = t.entry && typeof t.entry === 'object' ? t.entry : null;
5152
+ const st = e ? String(e.state || '') : '';
5153
+ const row = t.row && typeof t.row === 'object' ? t.row : null;
5154
+ const snap = { now: ctx.now, size: t.size, mtime: t.mtime };
5155
+ /*
5156
+ * ★ **확정·자동 폴더인데 후보 목록 밖인 행**(`t.c` 없음 — `autoCandidates` 가 빼는 `origin:'pull'` 행. Cursor 는 애초에
5157
+ * 폴더 답을 안 받아 `st === ''` 다)은 **옛 길로 보낸다**. 그 행은 「내 확정 폴더에서 남의 세션을 이어받아 계속 일한 대화」이고
5158
+ * (`resume_session` 이 받은 사본에 `origin:'pull'` 을 찍는다), **옛 ②가 올리던 바로 그것**이다 — 옛 ②는 cands 에 있는
5159
+ * bound 행만 건너뛰었고 pull 행은 cands 에 없어 그대로 올렸다. 여기서 null 로 떨구면 오너 결정(「옛 길의 뜻은 그대로」) 밖의
5160
+ * 뜻 변경이 되고, 사람에게는 아무 말도 안 나간 채 그 대화의 자동 갱신이 통째로 멎는다(반박 policy ②). 설계 §3.1 의
5161
+ * 「Cursor·pull 행은 지금처럼 여기(legacy)로 들어온다」도 이 쪽이다.
5162
+ * ⚠ 자동이 **꺼졌거나·낡았거나·ambiguous** 인 폴더는 아래 null ① 그대로다(privacy B5) — 오너가 끈 동의를 옛 길이 안 거스른다.
5163
+ */
5164
+ const boundOn = st === 'bound' && e.auto_upload === true && !e.stale;
5165
+ // ── bound ──
5166
+ if (boundOn && t.c) {
5167
+ if (ctx.client_old) return no('client_old'); // ① 만 끈다(서버가 426 으로 막을 자리)
5168
+ if (v.flags & BEAT_MOVED) return no('moved'); // 옮겨진 세션은 resolve 가 새 자리를 준다
5169
+ const first = v.verdict === BEAT_VERDICT.UNKNOWN;
5170
+ if (!first && v.verdict !== BEAT_VERDICT.AHEAD) return no('verdict'); // SAME·BEHIND 는 안 올린다
5171
+ const c = t.c;
5172
+ c.first = first; // 사본(dup) 은 첫 업로드 후보에만 건다(§2.10 · C8)
5173
+ // 색인이 「서버에 있다」고 아는 대화가 UNKNOWN 이면 사람이 가렸거나 걷은 것이다 — 첫 업로드가 아니다(§4.6).
5174
+ if (first && autoRowOnServer(row)) return no('row_on_server');
5175
+ // ★ 동의 문구가 먼저다(개인정보 M5·N8·N9) — 그 폴더의 §5.4 「bound·auto on」이 이 창에서 한 번 나간 뒤에만.
5176
+ if (!ctx.consent?.(e)) return no('no_consent'); // 판정자가 없으면 안 나간 것으로 친다(닫는 쪽)
5177
+ if ((ctx.tenant_wait?.get(Number(e.tenant_id)) || 0) > ctx.now) return no('tenant_wait');
5178
+ autoTooOldRefresh(c); // ★ 묻기 **전에** 머리를 채운다(호환 C2)
5179
+ let why = autoSkipReason(c, ctx);
5180
+ if (why) return no(why);
5181
+ autoHeadFill(c); // 서버 관문 11 이 재는 값(마지막 발화)으로 한 번 더(N3)
5182
+ // ⚠ 여기서 `rawShaOf(c.file)` 를 **조건 없이** 부르던 줄을 걷었다(§4.1 · C8): 그 한 줄이 **바퀴마다 전사 전체를 다시
5183
+ // sha256** 했고(크기·mtime 이 바뀌면 기억이 안 듣는다 — 자라는 전사는 매번 바뀐다), 쓰임은 사본 판정 한 곳뿐이었다.
5184
+ // 덧붙임의 「O(변화분)」 주장이 클라이언트 읽기에서 무너지는 자리다. 사본은 이제 첫 업로드 후보에서만 잰다.
5185
+ why = autoSkipReason(c, ctx); // 동의 앞 · 이미 올린 다른 uuid 의 사본(첫 업로드만)
5186
+ if (why) return no(why);
5187
+ if (autoIntervalOf(row, ctx.now)) return no('interval');
5188
+ why = autoGateOf(row, snap);
5189
+ return why ? no(why) : 'bound';
5190
+ }
5191
+ // ── null ① ── 확정 바인딩이 있는데 자동이 꺼졌거나·낡았거나·애매하다
5192
+ if (st === 'ambiguous') return no('ambiguous');
5193
+ if (st === 'bound' && !boundOn) return no(e.stale ? 'stale' : 'auto_off');
5194
+ // ── legacy ── 확정 바인딩이 없다(답이 없거나 unbound) · 또는 확정·자동 폴더의 후보 목록 밖 행(위 ★)
5195
+ if (st === '' || st === 'unbound' || boundOn) {
5196
+ if (v.verdict !== BEAT_VERDICT.AHEAD || !(v.flags & BEAT_WANT_PUSH)) return no('not_wanted');
5197
+ if (!t.rec?._tenant_arg || !(Number(row?.tenant_id) > 0)) return no('no_tenant');
5198
+ if (ctx.holds_ok === false) return no('holds_unreadable'); // 보류 명부를 못 읽으면 옛 길도 안 간다(H2)
5199
+ if (ctx.holds?.has?.(String(t.uuid).toLowerCase())) return no('hold'); // no_auto_push — 색인이 절단돼도 남는 자리
5200
+ if (row?.auto?.hold === true) return no('hold');
5201
+ const blocked = autoBlockedFor(row, row.tenant_id, ctx.aside?.get?.(String(t.uuid).toLowerCase()));
5202
+ if (blocked) return no(blocked); // 가림·보류 409 뒤에는 멈춘다(오너 결정 §0-5)
5203
+ if (autoIntervalOf(row, ctx.now)) return no('interval');
5204
+ const why = autoGateOf(row, snap);
5205
+ return why ? no(why) : 'legacy';
5206
+ }
5207
+ // ── null ② ── 그 밖(모르는 폴더 상태)
5208
+ return no(`state:${st}`);
5209
+ }
5210
+
5211
+ /**
5212
+ * 입구(§3.2) — 임대 → 임대 안에서 색인·대응표를 **다시** 읽고 모든 판정 → `pushSession` → 결과를 잠금 안 mutate 로 → 푼다.
5213
+ * @return {Promise<{sent: boolean, why: string}>} sent = 보내기를 시도했다(성공이든 실패든 — 한 바퀴에 한 건의 셈)
5214
+ */
5215
+ async function autoEntrance(t, cls, ctx) {
5216
+ const u = t.uuid;
5217
+ const u8 = u.slice(0, 8);
5218
+ const lease = leaseTake(u);
5219
+ if (!lease.h) {
5220
+ if (lease.err) warnOnce(`lease-${lease.err}`, `자동 올리기 임대 자리(${pushDir()})를 못 만들어 올리지 않는다(${lease.err}).`);
5221
+ return { sent: false, why: lease.held ? 'held_elsewhere' : `lease_${lease.err}` };
5222
+ }
5223
+ try {
5224
+ // ── 2. 임대 안에서 다시 읽는다 — 색인을 못 읽으면 올리지 않는다(닫는 쪽 · §5.1). `indexRead()` 는 못 읽음을 {} 로 삼키므로 직접 본다.
5225
+ const got = jsonReadRetry(INDEX_FILE);
5226
+ if (got.err || (got.raw !== null && !got.obj)) {
5227
+ warnOnce('auto-index-unreadable', `${INDEX_FILE} 를 못 읽어 자동 올리기를 멈춘다(무엇을 언제 올렸는지 모르므로) — 그 파일을 고치면 다시 돈다.`);
5228
+ return { sent: false, why: 'index_unreadable' };
5229
+ }
5230
+ const row = got.obj?.[u] && typeof got.obj[u] === 'object' ? got.obj[u] : null;
5231
+ const holds = autoHoldsRead();
5232
+ if (!holds.ok) return { sent: false, why: 'holds_unreadable' };
5233
+ const L = ledgerReadStrict(u);
5234
+ if (!L.ok) {
5235
+ warnOnce(`auto-ledger-unreadable-${u}`, `${u8} 의 대응표를 못 읽어 자동으로 안 올린다 — ${ledgerFile(u)} 의 권한을 고치거나 지우면 다시 돈다.`);
5236
+ return { sent: false, why: 'ledger_unreadable' };
5237
+ }
5238
+ let st;
5239
+ try { st = fs.statSync(t.file); } catch { return { sent: false, why: 'gone' }; }
3837
5240
  const now = Date.now();
3838
- const wait = autoBackoffMs(fails, status, err?.retry_after);
3839
- if (status === 429 && code === 'auto_rate') autoTenantWait.set(Number(e.tenant_id), now + wait);
3840
- if (status === 426) minClientTick(true); // 판 알림 뒤 24시간(§5.5 7)
3841
- /*
3842
- * ★ **`too_old` 는 그 세션에 영구다**(개인정보 N3 · 부록 D-7). 「동의 앞에 끝난 대화」는 내일도 같은 답이다 —
3843
- * 24시간 백오프로 두면 같은 검열본이 **날마다** 서버 디스크에 한 번 쓰였다 지워진다. 서버가 알려 준
3844
- * 동의 시각을 적어 두고, 그 대화가 **자라서** 마지막 발화가 그 시각을 넘을 때까지 다시 안 보낸다.
3845
- */
3846
- if (status === 409 && code === 'too_old') {
3847
- const at = Date.parse(String(err?.auto_upload_at || '').replace(' ', 'T'));
3848
- const floor = Number.isFinite(at) && at > 0 ? at : (autoFloorMs(e) || now);
3849
- // ⚠ 그때의 mtime 을 함께 적는다 — 다음 바퀴부터는 **파일이 바뀌었을 때만** 머리를 다시 읽는다(호환 C2).
3850
- indexPatch(c.uuid, { auto: { fails: 0, next_at: 0, last_code: code, too_old: floor, too_old_mtime: Number(c.mtime) || 0 } });
3851
- warnOnce(`auto-too-old-${c.uuid}`,
3852
- `${u8} 는 자동 올리기를 켜기 전에 끝난 대화라 안 올린다 — 다시 안 두드린다(올리려면 push_session).`);
3853
- return;
5241
+ const f = { now, size: st.size, mtime: st.mtimeMs };
5242
+ const aside = autoBlockAside.get(u.toLowerCase());
5243
+ let tenantArg;
5244
+ let tid;
5245
+ if (cls === 'bound') {
5246
+ const c = t.c;
5247
+ c.row = row;
5248
+ if (c.v?.verdict === BEAT_VERDICT.UNKNOWN && autoRowOnServer(row)) return { sent: false, why: 'row_on_server' };
5249
+ // 사본 거르개도 **임대 안에서 다시 읽은 색인**으로 짓는다(잠금 밖 스냅숏이 아니라 · race M4).
5250
+ const why = autoSkipReason(c, { now, env_on: autoPushOn(), dupOf: (x) => !!autoDupOf(x.uuid, x.file, got.obj || {}),
5251
+ holds: holds.set, holds_ok: holds.ok, aside: autoBlockAside });
5252
+ if (why) return { sent: false, why };
5253
+ tid = Number(c.entry.tenant_id) || 0;
5254
+ // 429 `auto_rate` 대기도 임대 안에서 **다시** 본다 — 잠금 밖 스냅숏에서만 보면 기다리는 동안 옆 창이 세운 대기를 못 본다(반박 policy).
5255
+ if ((autoTenantWait.get(tid) || 0) > now) return { sent: false, why: 'tenant_wait' };
5256
+ tenantArg = c.entry.tenant_slug || c.entry.tenant;
5257
+ } else {
5258
+ if (holds.set.has(u.toLowerCase()) || row?.auto?.hold === true) return { sent: false, why: 'hold' };
5259
+ tenantArg = tenantArgOf(row);
5260
+ tid = Number(row?.tenant_id) || 0;
5261
+ if (!tenantArg || !(tid > 0)) return { sent: false, why: 'no_tenant' };
5262
+ const blocked = autoBlockedFor(row, tid, aside);
5263
+ if (blocked) return { sent: false, why: blocked };
5264
+ }
5265
+ if (autoIntervalOf(row, now)) return { sent: false, why: 'interval' };
5266
+ const gate = autoGateOf(row, f);
5267
+ if (gate) return { sent: false, why: gate };
5268
+ // ── 3. 보낸다 — 사람과 같은 함수. 진전이 있을 때만 임대 시계를 민다.
5269
+ // 길(덧붙임/전체)은 `pushSession` 안의 `planPush` 이 **이 임대 안에서** 고른다(§3.2). 여기서 거는 것은 **예산** 하나다:
5270
+ // 진짜 FULL(재기반)만 세고, 첫 업로드와 옛 서버의 FULL 은 안 센다. 넘으면 `full_budget` 으로 멎고 그 상태가 색인에 남는다.
5271
+ const progress = () => leaseTouch(lease.h);
5272
+ const budgetAt = autoFullBudgetOf(row, now);
5273
+ const planGate = (plan) => (budgetAt > 0 && autoFullCounts(plan, t.v?.verdict) ? 'full_budget' : '');
5274
+ let r = null;
5275
+ let plan = null;
5276
+ try {
5277
+ ({ r, plan } = cls === 'bound'
5278
+ ? await pushSession(tenantArg, u, '', false, 'skip',
5279
+ { binding_id: Number(t.c.entry.binding_id) || 0, device: deviceId(), prepare: (sc) => autoPrepare(sc, t.c) },
5280
+ { progress, plan: planGate }, tid)
5281
+ : await pushSession(tenantArg, u, '', false, 'skip', null, { check: autoLegacyCheck, progress, plan: planGate }, tid));
5282
+ } catch (err) {
5283
+ if (String(err?.code || '') === 'full_budget') {
5284
+ // **조용히 버리지 않는다** — 다음 시간으로 미루고 까닭을 남긴다(사람이 「왜 안 올라가나」를 색인에서 읽는다).
5285
+ indexPatch(u, { auto: { fails: 0, next_at: budgetAt, last_code: 'full_budget' } });
5286
+ warnOnce(`auto-full-budget-${u}`, `${u8} 는 한 시간에 전체 재전송이 ${PUSH_FULL_BUDGET}번을 넘어 다음 시간까지 미룬다(덧붙임이 설 때까지).`);
5287
+ return { sent: false, why: 'full_budget' };
5288
+ }
5289
+ autoEntranceFailed(t, cls, err, { tid, tenant: cls === 'bound' ? (t.c.entry.tenant || t.c.entry.tenant_slug) : (row?.tenant || tenantArg), size: st.size, mtime: st.mtimeMs });
5290
+ return { sent: true, why: '' };
3854
5291
  }
3855
5292
  /*
3856
- * ★★ **사람이 뺀 대화는 물러섬이 아니라 붙박이다**(개인정보 NEW-4). `session_hidden`·`session_hold` 는
3857
- * 사람의 손이 세운 답이라 24시간 뒤에도 같다 — 그런데 백오프로 두면 **검열된 전사 전체가 날마다**
3858
- * 그 소속처 서버로 다시 보내지고 파싱된다(서버는 관문 앞에서 막으니 디스크에는 안 남는다 — 그래도
3859
- * 보낸 것은 보낸 것이다). 소속처를 함께 적어 두고, 그 소속처에 대해서만 다시 안 보낸다.
3860
- * 푸는 길은 `autoSkipReason()` 의 「푸는 길」 넷뿐이다(그 소속처로의 성공한 올리기 · 소속처 바뀜 · 그 소속처 resolve 의 `same`·`hold:false`).
5293
+ * ── 4. 결과는 잠금 안 mutate 로. `next_at = now + 간격` 은 같은 기계의 **옛 판 ①**(색인 next_at 을 읽는다)도 간격을
5294
+ * 지키게 하는 비춤이다 — 전에는 성공하면 `next_at:0` 으로 되돌려 「방금 보냈다」를 아무도 못 봤다.
5295
+ * ⚠ **성공한 뒤의 뒷처리는 제 try 로 가른다**(반박 race). 전에는 같은 catch 라, 색인 쓰기나 stderr(EPIPE)가 던지면
5296
+ * **보낸 것을 실패로 적어** fails 를 올리고 백오프를 걸었다.
3861
5297
  */
3862
- if (status === 409 && AUTO_STICKY_409.has(code)) {
3863
- indexPatch(c.uuid, { auto: { fails: 0, next_at: 0, last_code: code } });
3864
- // (판 8 — 적합성 F7) 표는 따로, 잠금 안 견줌으로 — 다른 소속처가 세운 표는 안 덮고 곁 표에 둔다.
3865
- try { autoBlockPut(c.uuid, code, Number(e.tenant_id) || 0, now); } catch { /* 못 적으면 다음 바퀴의 409 가 다시 세운다 */ }
3866
- warnOnce(`auto-blocked-${c.uuid}-${code}`, code === 'session_hidden'
3867
- ? `${u8} 는 사람이 «${e.tenant || e.tenant_slug}» 에서 가렸거나 걷은 대화라 자동으로 안 올린다 — 다시 안 두드린다(정말 올려야 하면 push_session).`
3868
- : `${u8} 는 «${e.tenant || e.tenant_slug}» 에서 「자동으로 올리지 않음」으로 표시된 대화라 안 올린다 — 다시 안 두드린다(포털 「폴더」의 업로드 목록에서 푼다).`);
3869
- return;
3870
- }
3871
- indexPatch(c.uuid, { auto: { fails, next_at: now + wait, last_code: code } });
3872
- if (code === 'too_big') warnOnce(`auto-too-big-${c.uuid}`, `${u8} 는 검열본이 32MB 를 넘어 자동으로 올리지 않는다 — 올리려면 push_session.`);
3873
- else beatSay(`auto-${c.uuid}-${code}`, `자동 올리기 보류(${u8} · ${code}) — ${err?.message || err}`);
5298
+ try {
5299
+ const done = Date.now();
5300
+ // ★ 예산의 기록은 **여기 한 곳**에서만 선다 — 진짜 FULL(재기반)이 **성공한** 때뿐이다(사람의 push 는 이 길로 안 온다).
5301
+ const counts = autoFullCounts(plan, t.v?.verdict);
5302
+ const wrote = indexWrite(u, null, (cur) => {
5303
+ const log = (Array.isArray(cur?.auto?.full_log) ? cur.auto.full_log : []).map(Number).filter((x) => Number.isFinite(x) && x > 0);
5304
+ return { auto: { last_at: done, fails: 0, next_at: done + AUTO_PUSH_MIN_MS, last_code: '', closed: null,
5305
+ ...(counts ? { full_log: [...log.slice(-(PUSH_FULL_LOG_MAX - 1)), done] } : {}),
5306
+ ...autoClearOnPush(Number(r?.tenant_id) || tid) } };
5307
+ }, '');
5308
+ if (!wrote) warnOnce(`auto-index-write-${u}`, `${u8} 를 올렸지만 색인에 못 적었다 — 옆 창이 간격을 못 볼 수 있다.`);
5309
+ const how = plan?.mode === 'APPEND' || plan?.mode === 'ASK' ? '덧붙여' : '통째로';
5310
+ process.stderr.write(cls === 'bound'
5311
+ ? `genetics: ${u8} 를 확정 폴더의 자동 올리기로 ${how} 올렸다 — «${r.tenant || t.c.entry.tenant}» · ${r.lines}줄.\n`
5312
+ : `genetics: ${u8} 를 자동으로 ${how} 올렸다 — ${r.lines}줄(서버가 낡아 있었다).\n`);
5313
+ } catch { /* 보낸 것은 성공이다 — 뒷처리 실패로 실패를 적지 않는다 */ }
5314
+ return { sent: true, why: '' };
5315
+ } finally {
5316
+ fileUnlock(lease.h);
5317
+ }
5318
+ }
5319
+
5320
+ /** 대응표를 읽는다 — 없으면 `{ok:true, obj:null}` · **있는데 못 읽으면(권한) `{ok:false}`** · 깨진 JSON 은 캐시일 뿐이라 없는 것으로. */
5321
+ function ledgerReadStrict(uuid) {
5322
+ const f = ledgerFile(uuid);
5323
+ if (!f) return { ok: true, obj: null };
5324
+ let raw;
5325
+ try { raw = fs.readFileSync(f, 'utf8'); } catch (e) { return e?.code === 'ENOENT' ? { ok: true, obj: null } : { ok: false, obj: null }; }
5326
+ try { const o = JSON.parse(raw); return { ok: true, obj: o && typeof o === 'object' && !Array.isArray(o) ? o : null }; } catch { return { ok: true, obj: null }; }
5327
+ }
5328
+
5329
+ /**
5330
+ * 입구의 실패 규칙 **한 곳**(§3.2 4 · §5.1) — 확정 폴더와 옛 길이 같은 규칙을 탄다(옛 길은 전에 stderr 한 줄뿐이라 409 가림을 받고도
5331
+ * 90초마다 다시 보냈다 — 오너 결정 §0-5 로 붙박이). 실패 수는 잠금 안에서 파일의 값을 읽어 +1 한다(race S6).
5332
+ */
5333
+ function autoEntranceFailed(t, cls, err, o) {
5334
+ const u = t.uuid;
5335
+ const u8 = u.slice(0, 8);
5336
+ const status = Number(err?.status) || 0;
5337
+ const code = String(err?.code || (status ? `http_${status}` : 'error'));
5338
+ const now = Date.now();
5339
+ const at = { last_at: now };
5340
+ if (status === 429 && code === 'auto_rate') autoTenantWait.set(Number(o.tid) || 0, now + autoBackoffMs(1, status, err?.retry_after));
5341
+ if (status === 426) minClientTick(true); // 판 알림 뒤 24시간(§5.5 7)
5342
+ /*
5343
+ * ★ **`too_old` 는 그 세션에 영구다**(개인정보 N3 · 부록 D-7). 「동의 앞에 끝난 대화」는 내일도 같은 답이다 —
5344
+ * 24시간 백오프로 두면 같은 검열본이 **날마다** 서버 디스크에 한 번 쓰였다 지워진다. 서버가 알려 준
5345
+ * 동의 시각을 적어 두고, 그 대화가 **자라서** 마지막 발화가 그 시각을 넘을 때까지 다시 안 보낸다.
5346
+ */
5347
+ /*
5348
+ * ⚠ 이 자리는 **확정 폴더(①) 몫이다** — 옛 길은 `too_old` 를 안 읽는다(오너 결정 §0-1: 32MB·동의·too_old·pull 을 안 씌운다).
5349
+ * 지금은 도달 불가이기도 하다: 서버의 `too_old` 는 바인딩 머리를 실은 요청에만 서는데(`lib/folder_binding.php` 의
5350
+ * `agent_session_gate_age` — `$auto === null` 이면 null) 옛 길은 그 머리를 안 싣는다. 뜻이 바뀌어 옛 길에도 오게 되면
5351
+ * `autoGateOf` 가 `too_old` 를 읽지 않으므로 90초마다 되풀이된다 — 그때는 게이트에 한 줄을 더해야 한다(반박 policy).
5352
+ */
5353
+ if (status === 409 && code === 'too_old') {
5354
+ const t0 = Date.parse(String(err?.auto_upload_at || '').replace(' ', 'T'));
5355
+ const floor = Number.isFinite(t0) && t0 > 0 ? t0 : (autoFloorMs(t.entry) || now);
5356
+ // ⚠ 그때의 mtime 을 함께 적는다 — 다음 바퀴부터는 **파일이 바뀌었을 때만** 머리를 다시 읽는다(호환 C2).
5357
+ indexPatch(u, { auto: { ...at, fails: 0, next_at: 0, last_code: code, too_old: floor, too_old_mtime: Number(t.mtime) || 0 } });
5358
+ warnOnce(`auto-too-old-${u}`, `${u8} 는 자동 올리기를 켜기 전에 끝난 대화라 안 올린다 — 다시 안 두드린다(올리려면 push_session).`);
5359
+ return;
3874
5360
  }
5361
+ /*
5362
+ * ★★ **사람이 뺀 대화는 물러섬이 아니라 붙박이다**(개인정보 NEW-4 · 옛 길은 오너 결정 §0-5). `session_hidden`·`session_hold` 는
5363
+ * 사람의 손이 세운 답이라 24시간 뒤에도 같다 — 백오프로 두면 **검열된 전사 전체가 날마다** 그 소속처 서버로 다시 보내지고
5364
+ * 파싱된다. 소속처를 함께 적어 두고, 그 소속처에 대해서만 다시 안 보낸다. 푸는 길은 `autoSkipReason()` 의 「푸는 길」 넷뿐이다.
5365
+ */
5366
+ if (status === 409 && AUTO_STICKY_409.has(code)) {
5367
+ indexPatch(u, { auto: { ...at, fails: 0, next_at: 0, last_code: code } });
5368
+ // (판 8 — 적합성 F7) 표는 따로, 잠금 안 견줌으로 — 다른 소속처가 세운 표는 안 덮고 곁 표에 둔다.
5369
+ try { autoBlockPut(u, code, Number(o.tid) || 0, now); } catch { /* 못 적으면 다음 바퀴의 409 가 다시 세운다 */ }
5370
+ warnOnce(`auto-blocked-${u}-${code}`, code === 'session_hidden'
5371
+ ? `${u8} 는 사람이 «${o.tenant}» 에서 가렸거나 걷은 대화라 자동으로 안 올린다 — 다시 안 두드린다(정말 올려야 하면 push_session).`
5372
+ : `${u8} 는 «${o.tenant}» 에서 「자동으로 올리지 않음」으로 표시된 대화라 안 올린다 — 다시 안 두드린다(포털 「폴더」의 업로드 목록에서 푼다).`);
5373
+ return;
5374
+ }
5375
+ /*
5376
+ * ★ **`behind` 는 실패가 아니다**(C6 · §2.9 race M6). 이 기계의 다른 창이 같은 대화를 **더 뒤까지** 먼저 올렸다 — 물러서지 않고
5377
+ * (fails 0) 간격만 비춘다(`next_at = now + 간격` — 성공과 같은 비춤, C7). 따라잡기는 C8 몫이다.
5378
+ */
5379
+ if (status === 409 && code === 'behind') {
5380
+ indexPatch(u, { auto: { ...at, fails: 0, next_at: now + AUTO_PUSH_MIN_MS, last_code: code } });
5381
+ return;
5382
+ }
5383
+ /*
5384
+ * ★ **`caught_up` 도 실패가 아니다**(C8). 덧붙임이 서버 좌표를 따라잡고 보니 보낼 줄이 없었다 — 대응표는 세웠고 전송은 0이다.
5385
+ * 백오프를 걸면 「좌표만 어긋났던」 대화가 몇 시간 멎는다. `behind` 와 같은 대우(fails 0 · 간격만 비춤).
5386
+ */
5387
+ if (code === 'caught_up') {
5388
+ indexPatch(u, { auto: { ...at, fails: 0, next_at: now + AUTO_PUSH_MIN_MS, last_code: code } });
5389
+ return;
5390
+ }
5391
+ const closed = AUTO_CLOSED_CODES.has(code) ? { closed: { code, size: Number(o.size) || 0, mtime: Number(o.mtime) || 0 } } : {};
5392
+ indexWrite(u, null, (row) => {
5393
+ const fails = (Number(row?.auto?.fails) || 0) + 1;
5394
+ return { auto: { ...at, fails, next_at: now + autoBackoffMs(fails, status, err?.retry_after), last_code: code, ...closed } };
5395
+ }, '');
5396
+ if (code === 'cwd_hist_overflow') warnOnce(`auto-cwd-hist-${u}`, `${u8} 는 거쳐 간 폴더가 ${CWD_HIST_MAX}곳을 넘어 사적 폴더 판정을 다 못 잰다 — 자동으로 안 올린다(올리려면 push_session).`);
5397
+ else if (code === 'private') warnOnce(`auto-private-${u}`, `${u8} 는 거쳐 간 자리에 .genetics-private 가 있어 자동으로 안 올린다(올리려면 push_session).`);
5398
+ else if (code === 'too_big') warnOnce(`auto-too-big-${u}`, `${u8} 는 검열본이 32MB 를 넘어 자동으로 올리지 않는다 — 올리려면 push_session.`);
5399
+ else beatSay(`auto-${u}-${code}`, `자동 올리기 보류(${u8} · ${code}) — ${err?.message || err}`);
3875
5400
  }
3876
5401
 
3877
- /** §5.5 5 — 심박 판정으로 갈라 한 바퀴에 한 건. 확정 폴더 밖의 앞선 사본은 옛 길(서버가 올려 달라고 할 때만). */
3878
- async function autoUploadRound(reply, recs, cands) {
5402
+ /**
5403
+ * 한 바퀴(§3.1) — ①과 ②를 합친다. 후보 = 확정 폴더 후보(`autoCandidates`, 내 세션 → 최근 순) + 심박이 AHEAD 라 한 나머지(옛 길).
5404
+ * 갈래는 `autoPolicyOf()` 가 정하고, 보내기를 **시도한** 한 건에서 멈춘다(한 바퀴에 한 건 — ①이 먼저라는 우선순위는 후보 순서가 진다).
5405
+ * 임대를 못 잡았거나 임대 안 판정에서 물러선 후보는 건너뛰고 다음 후보를 본다(보낸 것이 없으니 「한 건」을 안 쓴다).
5406
+ * @param {object|null} ctx0 심박 **앞에서** 뜬 스냅숏(`beatTick`) — 없으면 여기서 뜬다
5407
+ */
5408
+ async function autoSyncRound(reply, recs, cands, ctx0 = null) {
3879
5409
  if (!autoPushOn()) { autoPushSilence(); return; }
3880
5410
  if (!reply) return;
3881
- const ctx = autoContext();
5411
+ const ctx = ctx0 || autoContext();
3882
5412
  const verdict = new Map(reply.map((v) => [v.uuid, v]));
3883
5413
  for (const c of cands) {
3884
5414
  const v = verdict.get(c.uuid);
3885
5415
  if (v && (v.verdict === BEAT_VERDICT.SAME || v.verdict === BEAT_VERDICT.BEHIND)) autoSettled.set(c.uuid, `${c.size}:${c.mtime}`);
3886
5416
  }
3887
- // ① 확정·자동 폴더 — 거르개를 전부 지난 첫 후보 하나(내 세션 → 최근 순)
3888
- if (!autoClientOld()) {
3889
- for (const c of autoOrder(cands)) {
3890
- const v = verdict.get(c.uuid);
3891
- if (!v || (v.flags & BEAT_MOVED)) continue; // 옮겨진 세션은 resolve 가 새 자리를 준다
3892
- const first = v.verdict === BEAT_VERDICT.UNKNOWN;
3893
- if (!first && v.verdict !== BEAT_VERDICT.AHEAD) continue; // SAME·BEHIND 는 안 올린다
3894
- // 색인이 「서버에 있다」고 아는 대화가 UNKNOWN 이면 사람이 가렸거나 걷은 것이다 — **첫 업로드**가 아니다.
3895
- // 다시 올리면 가림(deleted_at)이 풀린다(§4.6 — 지우는 손을 기계가 되돌리지 않는다).
3896
- // ⚠ 「안다」의 자는 소속 칸이 아니라 **서버를 거친 표식**이다(autoRowOnServer) — mail 도구와 옛 판 입양이
3897
- // 소속 칸만 적어서, 그 행을 여기서 건너뛰면 그 대화는 첫 업로드를 영영 못 한다(2026-09-15 실측).
3898
- if (first && autoRowOnServer(c.row)) continue;
3899
- /*
3900
- * ★ **동의 문구가 먼저다**(개인정보 검토 M5·N8·N9). §5.4 「bound·auto on」 이 **그 폴더에 대해** 도구 응답
3901
- * 꼬리로 한 번 나간 뒤에만 그 폴더의 전사를 올린다. 전에는 부팅 직후 첫 심박이 도구 호출보다 먼저 올려서,
3902
- * genetics 도구를 한 번도 안 부른 세션이 **한 글자도 안 알린 채** 올라갔다.
3903
- * ⚠ 깃발은 폴더별이고(N8: 창 하나가 두 확정 폴더를 오갈 수 있다), **옆 창의 전사에도 건다**
3904
- * (N9: 후보의 대부분이 그것이다). 다만 옆 **대화 안에서는** 여전히 아무 말도 안 나간다 —
3905
- * 그 한계는 `autoConsentSaid` 머리말과 §5.5 가 적어 둔다.
3906
- */
3907
- if (!autoConsentGiven(c.entry)) continue;
3908
- if (!first && ctx.now - (autoPushAt.get(c.uuid) || 0) < AUTO_PUSH_MIN_MS) continue;
3909
- if ((autoTenantWait.get(Number(c.entry?.tenant_id)) || 0) > ctx.now) continue;
3910
- autoTooOldRefresh(c); // ★ 묻기 **전에** 머리를 채운다(호환 C2) — 아래 autoHeadFill 은 이 뒤에 선다
3911
- if (autoSkipReason(c, ctx)) continue;
3912
- autoHeadFill(c); // 서버 관문 11 이 재는 값(마지막 발화)으로 한 번 더(N3)
3913
- c.raw_sha = rawShaOf(c.file);
3914
- if (autoSkipReason(c, ctx)) continue; // 동의 앞 · 이미 올린 다른 uuid 의 사본 — 다음 후보로
3915
- if (!indexClaim(c.uuid, ctx.now + AUTO_PUSH_MIN_MS, ctx.now)) continue; // 옆 창이 먼저 잡았다
3916
- autoPushAt.set(c.uuid, ctx.now);
3917
- await autoUploadOne(c);
3918
- return; // 한 바퀴에 한 건
3919
- }
5417
+ const pctx = { ...ctx, client_old: autoClientOld(), consent: autoConsentGiven, tenant_wait: autoTenantWait };
5418
+ const byDir = autoFolderMap(resolveCacheRead(), ctx.now);
5419
+ const recOf = new Map((recs || []).map((r) => [r.uuid, r]));
5420
+ const list = [];
5421
+ const seen = new Set();
5422
+ for (const c of autoOrder(cands)) {
5423
+ seen.add(c.uuid);
5424
+ list.push({ uuid: c.uuid, c, file: c.file, size: c.size, mtime: c.mtime, entry: c.entry, row: c.row, rec: recOf.get(c.uuid) || null });
3920
5425
  }
3921
- // ② 옛 길 — 확정 바인딩이 없는 폴더에서 사람이 올린 세션이 앞섰을 때(서버가 올려 달라고 할 때만)
3922
5426
  for (const v of reply) {
3923
- if (v.verdict !== BEAT_VERDICT.AHEAD || !(v.flags & BEAT_WANT_PUSH)) continue;
3924
- if (cands.some((c) => c.uuid === v.uuid && c.entry?.state === 'bound')) continue; // 확정 폴더의 행은 ① 의 거르개가 정한다
3925
- const rec = recs.find((r) => r.uuid === v.uuid);
3926
- if (!rec?._tenant_arg) continue;
3927
- if (ctx.holds_ok === false) continue; // 보류 명부를 못 읽으면 옛 길도 안 간다(H2)
3928
- if (ctx.holds.has(String(v.uuid).toLowerCase())) continue; // no_auto_push — 색인이 절단돼도 남는 자리
3929
- if (indexRead()[v.uuid]?.auto?.hold === true) continue; // no_auto_push — 이 대화는 자동으로 안 올라간다
3930
- if (ctx.now - (autoPushAt.get(v.uuid) || 0) < AUTO_PUSH_MIN_MS) continue;
3931
- autoPushAt.set(v.uuid, ctx.now);
3932
- try {
3933
- const { r } = await pushSession(rec._tenant_arg, v.uuid, '', false, 'skip');
3934
- process.stderr.write(`genetics: ${v.uuid.slice(0, 8)} 를 자동으로 올렸다 — ${r.lines}줄(서버가 낡아 있었다).\n`);
3935
- } catch (e) {
3936
- // 실패는 알리되 멈추지 않는다. 다음 심박이 다시 시도한다.
3937
- process.stderr.write(`genetics: 자동 올리기 실패(${v.uuid.slice(0, 8)}) — ${e.message}\n`);
3938
- }
3939
- return; // 한 바퀴에 한 건
5427
+ if (seen.has(v.uuid) || v.verdict !== BEAT_VERDICT.AHEAD) continue;
5428
+ seen.add(v.uuid);
5429
+ const file = transcriptOf(v.uuid);
5430
+ if (!file) continue;
5431
+ let st;
5432
+ try { st = fs.statSync(file); } catch { continue; }
5433
+ const hit = isCursorTranscript(file) ? null : byDir.get(path.dirname(file));
5434
+ const row = ctx.idx?.[v.uuid] && typeof ctx.idx[v.uuid] === 'object' ? ctx.idx[v.uuid] : null;
5435
+ list.push({ uuid: v.uuid, c: null, file, size: st.size, mtime: st.mtimeMs, entry: hit ? hit.entry : null, row, rec: recOf.get(v.uuid) || null });
5436
+ }
5437
+ for (const t of list) {
5438
+ t.v = verdict.get(t.uuid) || null;
5439
+ if (t.c) t.c.v = t.v;
5440
+ const cls = autoPolicyOf(t, pctx);
5441
+ if (!cls) continue;
5442
+ const r = await autoEntrance(t, cls, pctx);
5443
+ if (r.sent) return; // 한 바퀴에 한 건
3940
5444
  }
3941
5445
  }
3942
5446
 
@@ -3985,7 +5489,8 @@ async function beatTick() {
3985
5489
  // 확정·자동 폴더의 후보(§5.5) — 서버 소속을 이 기계가 모르는 사본도 판정을 받게 심박에 싣는다.
3986
5490
  let cands = [];
3987
5491
  let extra = [];
3988
- try { cands = autoCandidates(); extra = autoBeatRecs(cands, autoContext()); } catch { /* 후보를 못 모아도 심박은 뛴다 */ }
5492
+ let ctx0 = null; // 심박 **앞의** 스냅숏 — 싼 앞거르개용일 뿐, 입구가 임대 안에서 다시 읽는다(C7)
5493
+ try { cands = autoCandidates(); ctx0 = autoContext(); extra = autoBeatRecs(cands, ctx0); } catch { /* 후보를 못 모아도 심박은 뛴다 */ }
3989
5494
  const recs = beatTargets(extra);
3990
5495
  if (!recs.length) return;
3991
5496
  // **안 바뀌었으면 안 보낸다.** 이 설계가 실제로 버는 것은 바이트가 아니라 이것이다.
@@ -3996,7 +5501,7 @@ async function beatTick() {
3996
5501
  const reply = await beatOnce(recs);
3997
5502
  // 옮겨진 세션은 resolve 가 새 자리를 준다(심박 프레임은 그대로 — §4.5).
3998
5503
  try { await resolveMoved(reply); } catch { /* 다음 바퀴에 다시 */ }
3999
- try { await autoUploadRound(reply, recs, cands); } catch { /* 한 바퀴가 실패해도 심박은 계속 뛴다 */ }
5504
+ try { await autoSyncRound(reply, recs, cands, ctx0); } catch { /* 한 바퀴가 실패해도 심박은 계속 뛴다 */ }
4000
5505
  } finally {
4001
5506
  beatRunning = false;
4002
5507
  }
@@ -4152,8 +5657,74 @@ function taskReadResult(name, args, body) {
4152
5657
  return result;
4153
5658
  }
4154
5659
 
5660
+ /**
5661
+ * 할 일에 **완료 보고**를 올리는 문(M1 · 2026-09-26) — 사람이 화면에서 쓰는 것과 **같은 서버 함수**(`work_task_do_report`)다.
5662
+ *
5663
+ * 규율(DESIGN-work-tasks.md §4·§7): 세포가 done 을 올리면 완료가 아니라 **검수**다 — 도장은 사람 검수자가 찍는다. 글은 근거가
5664
+ * 아니다 — 근거는 (kind, ref) 튜플이고, 안 주면 **이 세션**을 근거로 싣는다(세션은 서버가 실재를 대조할 수 있는 갈래다).
5665
+ * 응답은 서버의 문장을 그대로 나른다 — 「완료로 표시했습니다」는 어느 갈래에도 없다(G18).
5666
+ */
5667
+ function taskWriteTools() {
5668
+ const tenant = { type: 'string', minLength: 1, maxLength: 100, description: 'list_my_tenants에서 확인한 조직 slug' };
5669
+ return [
5670
+ { name: 'report_task',
5671
+ description: '할 일에 완료 보고(또는 대화)를 남깁니다 — 무엇을 어떻게 했는지 본문으로, 확인할 수 있는 근거를 (kind, ref) 로. '
5672
+ + 'kind 가 done(기본)이면 그 할 일은 검수로 올라가고 완료 도장은 사람이 찍습니다. 근거를 안 주면 이 세션을 근거로 싣습니다'
5673
+ + '(세션·커밋 같은 내부층 근거는 우리 팀 자격만 — member 자격이면 근거 없이 글만 남고 그렇게 말합니다). '
5674
+ + '할 일 uid 는 list_tasks 에서 받습니다.',
5675
+ inputSchema: { type: 'object', additionalProperties: false, required: ['tenant', 'task_uid', 'body'], properties: { tenant,
5676
+ task_uid: { type: 'string', pattern: '^[a-z0-9]{8}$', description: 'list_tasks 가 준 할 일 uid' },
5677
+ body: { type: 'string', minLength: 1, maxLength: 4000, description: '무엇을 어떻게 했나 — 사람이 읽는 글. 근거를 대신하지 않는다' },
5678
+ kind: { type: 'string', enum: ['done', 'talk'], default: 'done', description: 'done = 완료 보고(검수로 올림) · talk = 대화만' },
5679
+ evidence: { type: 'array', maxItems: 10, description: '확인할 수 있는 근거. 비우면 {kind:"session", ref:<이 세션>} 하나',
5680
+ items: { type: 'object', additionalProperties: false, required: ['kind', 'ref'], properties: {
5681
+ kind: { type: 'string', enum: ['session', 'commit', 'file', 'case', 'report', 'decision'] },
5682
+ ref: { type: 'string', minLength: 1, maxLength: 300, description: '세션 uuid · 커밋 해시 · 파일 id · cases/…md · reports/<id>#<uid> · decisions/…md' },
5683
+ note: { type: 'string', maxLength: 300 } } } },
5684
+ done_when: { type: 'string', maxLength: 500, description: '완료 조건 — 그 할 일에 아직 없을 때만 채워진다' } } } },
5685
+ ];
5686
+ }
5687
+
5688
+ /** 요청 다듬기 — 모양만. 자격·소속처·세션의 실재는 서버가 본다. */
5689
+ function taskWriteRequest(args) {
5690
+ if (!args || typeof args !== 'object' || Array.isArray(args) || typeof args.tenant !== 'string'
5691
+ || !args.tenant.trim() || [...args.tenant].length > 100) throw new Error('조직 slug를 확인하세요.');
5692
+ if (typeof args.task_uid !== 'string' || !/^[a-z0-9]{8}$/.test(args.task_uid)) throw new Error('list_tasks에서 받은 할 일 uid가 필요합니다.');
5693
+ const body = typeof args.body === 'string' ? args.body.trim() : '';
5694
+ if (!body || [...body].length > 4000) throw new Error('완료 보고 본문은 1~4,000자입니다.');
5695
+ const kind = args.kind === 'talk' ? 'talk' : 'done';
5696
+ const mine = mySessionId();
5697
+ let evidence = Array.isArray(args.evidence) ? args.evidence : [];
5698
+ if (evidence.length > 10) throw new Error('근거는 한 번에 10개까지입니다.');
5699
+ evidence = evidence.map((e) => {
5700
+ if (!e || typeof e !== 'object' || typeof e.kind !== 'string' || typeof e.ref !== 'string' || !e.ref.trim()) throw new Error('근거는 kind 와 ref 가 함께 있어야 합니다.');
5701
+ const out = { kind: e.kind, ref: e.ref.trim().slice(0, 300) };
5702
+ if (typeof e.note === 'string' && e.note.trim()) out.note = e.note.trim().slice(0, 300);
5703
+ return out;
5704
+ });
5705
+ // 근거를 안 줬으면 **이 세션**이 근거다 — 세션 이름을 못 찾으면 지어내지 않고 빈 채로 보낸다(서버가 근거 없음을 말한다).
5706
+ const defaulted = !evidence.length && !!mine;
5707
+ if (defaulted) evidence = [{ kind: 'session', ref: mine }];
5708
+ const req = { tenant: args.tenant, body, kind, evidence, session: mine || '' };
5709
+ if (typeof args.done_when === 'string' && args.done_when.trim()) req.done_when = args.done_when.trim().slice(0, 500);
5710
+ return { path: '/api/work/tasks/' + args.task_uid + '/report', body: req, defaulted };
5711
+ }
5712
+
5713
+ /** 응답 한 줄 — 수와 상태 낱말은 서버의 것, 문장(says)은 그대로 나른다. */
5714
+ function taskWriteResult(uid, r) {
5715
+ const c = r?.comment || {};
5716
+ const t = r?.task || {};
5717
+ const counts = t.evidence_counts || {};
5718
+ const n = Number(counts.total) || (Array.isArray(c.evidence) ? c.evidence.length : 0);
5719
+ const v = Number(counts.usable) || 0;
5720
+ const head = `«${uid}» — ${c.kind_word || '완료 보고'} #${c.id ?? '?'} 올림 · 근거 ${n}(검증 ${v})`;
5721
+ const state = t.state_word ? ` · 상태: ${t.state_word}` : '';
5722
+ return head + state + '.' + (r?.says ? ' ' + r.says : '');
5723
+ }
5724
+
4155
5725
  const TOOLS = [
4156
5726
  ...taskReadTools(),
5727
+ ...taskWriteTools(),
4157
5728
  /*
4158
5729
  * ★ **이 도구는 부르지 않아도 일한다.** 설명 자체가 세계 사실을 실어 나른다(`toolsNow()`).
4159
5730
  * 도구 목록은 매 턴 모델 문맥에 있으므로, 부르기 전에 이미 눈앞에 놓인다.
@@ -4318,7 +5889,7 @@ const TOOLS = [
4318
5889
  'HTML 을 만들지 말 것 — 내용만 주면 서버가 렌더한다.\n' +
4319
5890
  '**기본은 초안이다** — 만들어도 고객에게는 보이지 않는다. 눈으로 확인한 뒤 ' +
4320
5891
  'release_report 로 열어 준다. 바로 열려면 publish: true 를 명시할 것.\n' +
4321
- '**문체는 두 면이다** — 우리가 한 일은 개조식(~함/~했음/~임), **상대가 할 일에는 높임 어간을 살린다**. 「회신 필요」가 아니라 「회신 주시면 됨」, 「~해 주셔야 함」이 아니라 명사형 「~ 요청」. 개조식을 상대에게 쓰면 명령형이 된다. dry_run 이 경고로 센다.\n' +
5892
+ '**문체는 주어와 자리가 정한다** — 요청 자리 밖에는 우리·사물·화면이 주어인 문장만 개조식(~함/~했음/~임)으로 쓴다. 독자가 할 일은 요청 자리(open_items·cta·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄)에만 두고 사실 문장까지 하십시오체로 맺는다(「회신 부탁드립니다」. 제목은 검수·보고면 「~ 요청」+기한 괄호, 결정 문서의 판단 항목은 물음). 그 밖의 칸에는 사실만 적는다(「기준 수치는 미정」). dry_run 은 일부만 센다.\n' +
4322
5893
  '쓰기 전에 결과만 보고 싶으면 dry_run: true — 렌더까지 돌려 경고를 주고 아무것도 저장하지 않는다.',
4323
5894
  inputSchema: {
4324
5895
  type: 'object',
@@ -4393,7 +5964,7 @@ const TOOLS = [
4393
5964
  '**그림 계약**: `assets` 를 **빼고** 보내면 서버가 저장된 그림을 그대로 유지한다. 전부 지우려면 `assets: []` 를 명시하고 ' +
4394
5965
  '`sections[].plates`·`chapters[].asset` 에서도 그 id 를 빼야 한다 — 가리키는 곳이 없는 id 는 422 로 거절된다. ' +
4395
5966
  '한 장만 바꾸려면 그 id 에 새 `source`/`file` 을 주고 나머지는 get_report 가 준 표식 그대로 두면 된다.\n' +
4396
- '**문체는 두 면이다** — 우리가 한 일은 개조식(~함/~했음/~임), **상대가 할 일에는 높임 어간을 살린다**. 「회신 필요」가 아니라 「회신 주시면 됨」, 「~해 주셔야 함」이 아니라 명사형 「~ 요청」. 개조식을 상대에게 쓰면 명령형이 된다. dry_run 이 경고로 센다.\n',
5967
+ '**문체는 주어와 자리가 정한다** — 요청 자리 밖에는 우리·사물·화면이 주어인 문장만 개조식(~함/~했음/~임)으로 쓴다. 독자가 할 일은 요청 자리(open_items·cta·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄)에만 두고 사실 문장까지 하십시오체로 맺는다(「회신 부탁드립니다」. 제목은 검수·보고면 「~ 요청」+기한 괄호, 결정 문서의 판단 항목은 물음). 그 밖의 칸에는 사실만 적는다(「기준 수치는 미정」). dry_run 은 일부만 센다.\n',
4397
5968
  inputSchema: {
4398
5969
  type: 'object',
4399
5970
  properties: {
@@ -4446,11 +6017,12 @@ const TOOLS = [
4446
6017
  evidence: { type: 'string', description: '근거 — 실측치·커밋 해시·로그 한 줄. 비면 "안 적혔다"로 남는다.' },
4447
6018
  tags: { type: 'array', items: { type: 'string' }, description: '갈래(최대 8).' },
4448
6019
  paths: { type: 'array', items: { type: 'string' },
4449
- description: '이 사례가 걸린 **저장소 상대 경로**(예: www/portal/lib/x.php). 본문에 적힌 경로는 자동으로 들어간다.' },
6020
+ description: '이 사례가 걸린 **저장소 상대 파일 경로**(예: www/portal/lib/x.php — 빗금 필수). 본문에 적힌 경로는 자동으로 들어간다. 설비 이름·맨 파일명은 열쇠가 못 된다(세포가 실재 파일 경로와 정확 일치로만 맞힌다) — 서버가 돌려보내며 말한다.' },
4450
6021
  commands: { type: 'array', items: { type: 'string' },
4451
6022
  description: '이 사례가 걸린 **명령 문구**(예: "INSERT INTO rp_agent_presence"). 다른 세포가 그 문구가 든 명령을 치기 직전에 이 사례를 본다.' },
4452
6023
  errors: { type: 'array', items: { type: 'string' },
4453
- description: '이 사례의 **오류 문자열**(예: "CONSTRAINT ck_tree_machine failed"). 지금은 적어 두기만 한다.' },
6024
+ description: '이 사례의 **오류 문자열**(예: "CONSTRAINT ck_tree_machine failed"). 지금은 **관측 쪽**에서만 쓴다 — 사례가 닿은 뒤 같은 오류가 또 났는지를 실패 지문과 대조한다. 도구 직전 알림에는 아직 안 쓴다(세포는 paths·commands 만 맞힌다).' },
6025
+ work: { type: 'string', description: '이 사례가 **어느 일의 시정조치 기록**인지 — 그 일의 열쇠(영문 소문자·숫자·붙임표, 예: hnl-wait). 결정의 work 와 같은 규칙으로 갈래 맨 앞에 접힌다. 부적합(사례) → 조치(일) → 근거(완료)가 한 줄로 이어지게 하는 자리다.' },
4454
6026
  picks: { type: 'array', maxItems: 20, items: { type: 'object', required: ['selector', 'text'], properties: {
4455
6027
  selector: { type: 'string', description: '페이지에서 다시 잴 자리 — 훅 줄의 sel `…` 그대로' },
4456
6028
  text: { type: 'string', description: '사람이 본 글자 — 훅 줄의 "…" 그대로' },
@@ -4489,7 +6061,9 @@ const TOOLS = [
4489
6061
  when: { type: 'string', description: '정한 날짜(예: 2026-08-27). 모르면 비운다 — 오늘로 지어내지 말 것.' },
4490
6062
  why: { type: 'string', description: '그때의 근거 — 수치·관찰. 이 칸이 이 장의 값이다.' },
4491
6063
  reopen: { type: 'string', description: '무슨 조건이면 다시 여나. **비우면 「열린 결정」으로 읽힌다.** 끝났으면 "조건 없음(종결)".' },
4492
- status: { type: 'string', description: '상태 — "유효"(기본) · "뒤집힘 → decisions/<그 결정>.md" · "조건 충족 → <무엇이> (날짜)". ★ 「뒤집힘」은 **무엇을 뒤집는지 대야 저장된다** — 경로가 없거나 그 장이 실재하지 않으면 거절한다(get_case 가 준 경로 그대로). 가리킬 장이 아직 없으면 그 결정을 먼저 남겨라.' },
6064
+ status: { type: 'string', description: '상태 — "유효"(기본) · "뒤집힘 → decisions/<이 결정을 뒤집은 장>" · "조건 충족 → <무엇이> (날짜)". ★ 「뒤집힘」은 **옛 결정을 다시 저장할 때** 적는 값이고, **무엇으로 뒤집혔는지** 대야 저장된다 — 경로가 없거나 그 장이 실재하지 않으면 거절한다(get_case 가 준 경로 그대로). **새 결정이 옛 결정을 뒤집는 것이면 status 가 아니라 overturns 를 쓴다** — 새 결정의 status 에 「뒤집힘」을 적으면 그 새 결정이 폐기로 읽힌다.' },
6065
+ overturns: { type: 'string', description: '이 결정이 **뒤집는 옛 결정**의 경로(decisions/<그 결정>, get_case 가 준 것 그대로). 적으면 서버가 **같은 커밋**에서 옛 결정의 상태를 「뒤집힘 → 이 결정」으로 고치고, 이 결정 본문에 「그 결정을 뒤집는다」를 남긴다(양쪽 연결). 이 결정의 status 는 비워 둔다(유효로 선다). ★ overturns_revision 이 없으면 거절한다 — 읽지 않은 장은 뒤집을 수 없다.' },
6066
+ overturns_revision: { type: 'string', pattern: '^[a-f0-9]{64}$', description: 'overturns 로 댄 옛 결정을 get_case 로 읽은 revision 그대로. 그 사이 누가 그 장을 고쳤으면 409 로 멈춘다 — 다시 읽고 판단을 다시 한다.' },
4493
6067
  tags: { type: 'array', items: { type: 'string' }, description: '갈래(최대 8). ★ 이것이 있어야 이 결정이 「일」 후보 계산에 들어간다.' },
4494
6068
  work: { type: 'string', description: '**이 결정이 곧 과업이면** 그 일의 열쇠(영문 소문자·숫자·붙임표, 예: hnl-wait). 적으면 그 이름의 일이 **지금** 「계획」으로 선다 — 착수 전에 세우는 정문이다. 이미 열린 일의 열쇠면 이 결정이 그 일의 **항목**(작업 리스트 한 줄)으로 서고 간트에 저장 즉시 보인다 — 항목 여럿이면 결정을 여러 번 적는다(항목마다 제 근거를 갖는다).' },
4495
6069
  order: { type: 'array', items: { type: 'string' }, description: '일 사이 **순서**를 정하는 결정이면 일의 열쇠를 앞선 것부터(최대 24). 표에 저장하지 않고 일 목록이 이 결정을 읽어 그 순서로 선다 — 순서가 바뀌면 새 결정을 적는다.' },
@@ -4504,10 +6078,34 @@ const TOOLS = [
4504
6078
  source: { type: 'string', description: '고칠 자리 — 훅 줄의 <저장소 상대 경로>:<줄> 그대로(없으면 비움)' },
4505
6079
  url: { type: 'string', description: '그 화면 주소(알면)' },
4506
6080
  } }, description: '사용자가 브라우저에서 **집은 요소**(iab 픽) — remember_case 의 같은 칸. 사용자가 "iab N" 이라 했으면 훅 줄의 #N 에서 그대로 옮긴다, 지어내지 않는다. 장에 「집은 자리」 절로 선다(최대 20).' },
6081
+ // 소속처가 반려한 이름(2026-09-26) — 목록의 정본이 이 칸이다. 모양 검사는 창구(wiki_rejected_names_clean)가 한다.
6082
+ rejected_names: { type: 'array', maxItems: 20, items: { type: 'object', required: ['name'], properties: {
6083
+ name: { type: 'string', description: '반려된 이름 — 작업하며 지은 판·환경·인스턴스 이름(공백 빼고 2~40자)' },
6084
+ use: { type: 'string', description: '대신 쓸 원래 이름 — 독자 화면·문서의 이름(80자까지)' },
6085
+ near: { type: 'array', maxItems: 8, items: { type: 'string' }, description: '흔한 낱말이라 문맥으로만 가를 이름이면 함께 설 낱말(각 20자까지) — 그 낱말이 같은 문장 12자 안에 설 때만 센다. 정규식은 받지 않는다' },
6086
+ } }, description: '**반려가 작업하며 지은 이름을 짚었으면** 그 이름과 대신 쓸 원래 이름(최대 20). 이 소속처의 문서 발행·모의 발행·로컬 렌더가 경고로 센다(list_rejected_names 로 확인). 반려 날짜와 까닭은 when·why 에 적는다. 같은 제목으로 다시 적으면 목록도 통째로 바뀐다 — 빠진 이름은 응답이 말한다.' },
4507
6087
  },
4508
6088
  required: ['tenant', 'title', 'what', 'commit_id'],
4509
6089
  },
4510
6090
  },
6091
+ {
6092
+ // 소속처가 반려한 이름(2026-09-26) — 로컬 렌더(render.py)·발행 직전 훅이 서버를 안 거치고도 발행과 같은 목록으로 세게 하는 창구.
6093
+ // 원격(/mcp)에는 두지 않는다 — 그쪽은 렌더가 서버에서 돌아 소비자가 없다.
6094
+ name: 'list_rejected_names',
6095
+ description:
6096
+ '**이 소속처가 반려한 이름 목록**을 읽는다 — 결정 장(remember_decision 의 rejected_names 칸)에서 이름마다 가장 새 결정을 모은다(뒤집힌 결정은 뺀다). '
6097
+ + '문서 발행·모의 발행이 렌더러에 넘겨 경고로 세는 **그 목록**이다. 읽기만 한다.\n'
6098
+ + '언제 부르는가: 반려 이름을 적은 뒤 확인할 때 · 로컬 렌더(render.py --tenant)와 발행 직전 훅이 부른다(format: "json").\n'
6099
+ + '0 이면 0 이라고 말하고, 결정 장의 줄이 깨졌으면 그 장을 짚는다.',
6100
+ inputSchema: {
6101
+ type: 'object',
6102
+ properties: {
6103
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
6104
+ format: { type: 'string', enum: ['text', 'json'], description: '"json" 이면 기계가 읽을 JSON 한 덩어리({tenant, tenant_slug, names:[{name,use,near}], counted, skipped, note}). 기본은 사람이 읽을 글.' },
6105
+ },
6106
+ required: ['tenant'],
6107
+ },
6108
+ },
4511
6109
  {
4512
6110
  name: 'forget_decision',
4513
6111
  description:
@@ -4587,6 +6185,8 @@ const TOOLS = [
4587
6185
  + '잘못 표시했으면 work+item+undone:true 로 되돌린다(사후 정정 — 조용히 지워지지 않고 원장에 남는다).\n'
4588
6186
  + '**사람이 화면에서 체크박스로 표시하는 것은 이것의 별도 경로일 뿐, 필수 경로가 아니다** — '
4589
6187
  + '두뇌(에이전트) 스스로 완료를 확인했으면 사람이 누르기를 기다리지 않고 바로 이 도구로 표시한다.\n'
6188
+ + '**그 일의 항목 번호·제목·계획 기간·상태가 필요하면 work(slug)만 주고 부른다** — 항목 목록이 한 번에 온다'
6189
+ + '(결정 장을 한 장씩 열어 번호를 찾지 말 것).\n'
4590
6190
  + '요지 전문은 get_case(tenant, "work/<slug>.md") 로 읽는다. **0 이면 0 이라고 말한다.**',
4591
6191
  inputSchema: {
4592
6192
  type: 'object',
@@ -4594,7 +6194,7 @@ const TOOLS = [
4594
6194
  tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
4595
6195
  claim: { type: 'string', description: '이 일(slug)에 붙겠다고 선언한다 — 잠정이며, 다른 일을 선언하면 앞 선언은 대체된다. 비우면 목록만 읽는다.' },
4596
6196
  item: { type: 'string', description: 'claim 과 함께 쓰면 착수 선언(잠정). work 와 함께 쓰면 완료 확인/표시/해제 대상 — 이 일 안의 결정 항목(item_key, 예: decisions/foo.md).' },
4597
- work: { type: 'string', description: '이 일(slug)의 특정 항목을 완료로 확인·표시·해제할 때 쓴다 — claim 과 별개(착수 선언 없이도 완료를 다룰 수 있다). item 과 함께 쓴다.' },
6197
+ work: { type: 'string', description: '이 일(slug). **혼자 주면 그 일의 항목 목록**(번호·제목·계획 기간·상태·item_key)을 읽는다(부작용 없음). item 과 함께 주면 그 항목의 완료 확인·표시·해제 — claim 과 별개(착수 선언 없이도 완료를 다룰 수 있다).' },
4598
6198
  done_when: { type: 'string', description: '이 항목의 완료 조건 — 무엇이 되면 끝인가. done_evidence 와 반드시 함께 채운다(work+item과 함께).' },
4599
6199
  done_evidence: { type: 'string', description: 'done_when 과 함께 쓴다 — 그 조건이 실제로 충족됐다는 구체적 관측(파일·시험 결과·커밋 등). 비우면 완료로 못 간다 — 지어내지 말 것, 스스로 확인한 것만.' },
4600
6200
  undone: { type: 'boolean', description: '이 항목의 완료 표시를 되돌린다(사후 정정) — work+item 과 함께 쓴다.' },
@@ -5060,13 +6660,16 @@ function worldWho(c) {
5060
6660
  return '누군가';
5061
6661
  }
5062
6662
 
5063
- function worldFacts(max = 8) {
6663
+ function worldFacts(max = 8, strict = false) {
5064
6664
  const { bin } = cellCheck();
5065
6665
  if (!bin) return null; // 세포가 없다 — 「없다」와 구별한다
5066
6666
  try {
5067
6667
  const r = spawnSync(bin, ['delta', '--root', process.cwd(), '--json'],
5068
6668
  { encoding: 'utf8', timeout: 800 });
5069
- if (r.error || r.status !== 0 || !r.stdout) return [];
6669
+ // `strict` 는 알림 기준선(notifyToolsChanged)의 몫이다 — 시간 초과·실패는 「조용함」이 아니라 「못 읽음」이다.
6670
+ // 빈 목록을 기준선으로 삼으면 다음 정상 답이 변화로 읽혀 알림이 한 번 샌다(2026-09-28 게시 관문 notify 대조군 실측).
6671
+ if (r.error || r.status !== 0) return strict ? null : [];
6672
+ if (!r.stdout) return [];
5070
6673
  const d = JSON.parse(r.stdout);
5071
6674
  const ch = Array.isArray(d.changes) ? d.changes : [];
5072
6675
  const out = ch.slice(0, max).map((c) => {
@@ -5402,7 +7005,7 @@ let toolsNotifyReady = false; // initialize 전에는 안 보낸다
5402
7005
  let SEND = null;
5403
7006
 
5404
7007
  function notifyToolsChanged(sendFn) {
5405
- const facts = worldFacts(3);
7008
+ const facts = worldFacts(3, true);
5406
7009
  const sig = facts === null ? null : facts.join('|');
5407
7010
  if (sig === null) return; // 세포를 못 읽었다 — 지어내지 않는다
5408
7011
  if (toolsSig === null) { toolsSig = sig; return; } // 첫 관측은 기준선일 뿐이다
@@ -5870,6 +7473,38 @@ function requireArgs(name, args) {
5870
7473
  + `받는 이름은 이것뿐입니다 — ${all}`);
5871
7474
  }
5872
7475
 
7476
+ /**
7477
+ * ── `works(tenant, work)` — 그 일의 항목 한 줄씩(GET /api/work/items, 2026-09-27) ─────────────
7478
+ *
7479
+ * 오너: 「매번 너한테 일감 항목 번호 가져오라고 하는 것 부터가 문제로보여」. 세포는 항목 번호를 알려고
7480
+ * 결정 장을 한 장씩 열었다(같은 날 hnl 세션: 배정안 + 항목 장 일곱). 서버가 간트와 같은 자로 낸 줄을
7481
+ * 그대로 옮긴다 — 번호(code)는 제목 머리의 [ … ] 이고, 없으면 제목만 쓴다(지어내지 않는다).
7482
+ * ⚠ **서버가 준 칸만 쓴다** — 계획·상태 칸이 없거나 비면 그 토막을 안 그린다(옛 서버·빈 관측).
7483
+ */
7484
+ function worksItemsList(r) {
7485
+ const works = Array.isArray(r?.works) ? r.works : [];
7486
+ if (!works.length) return `${r?.tenant || ''} — 열린 일이 없다. 0건은 0건이다.`;
7487
+ const md = (d) => String(d || '').slice(5); // 2026-09-14 → 09-14
7488
+ const out = [];
7489
+ for (const w of works) {
7490
+ const items = Array.isArray(w?.items) ? w.items : [];
7491
+ out.push(`«${w.name || w.slug}» (${w.slug}) — 항목 ${items.length}개`);
7492
+ for (const it of items) {
7493
+ const code = typeof it.code === 'string' && it.code !== '' ? it.code : '';
7494
+ const title = code ? String(it.title || '').replace(/^\[[^\]]*\]\s*/, '') : String(it.title || '');
7495
+ // 계획 끝 말(plan_word)·고유 번호(uid)도 서버가 준 것만 — 할 일 화면과 같은 자(work_task_due_word) · 할 일 표의 uid.
7496
+ const due = it.plan_word && it.plan_word.text && it.plan_word.text !== '—' ? ` (${it.plan_word.text})` : '';
7497
+ const plan = it.plan && it.plan.from ? ` · 계획 ${md(it.plan.from)}~${md(it.plan.to)}${due}` : '';
7498
+ const state = it.state_word ? ` · ${it.state_word}` : '';
7499
+ const uid = typeof it.uid === 'string' && it.uid !== '' ? ` · uid ${it.uid}` : '';
7500
+ out.push(` · ${code ? code + ' ' : ''}${title}${plan}${state} — ${it.key}${uid}`);
7501
+ }
7502
+ if (Number(w.dropped) > 0) out.push(` (지시형 제목이라 뺀 결정 ${Number(w.dropped)}건)`);
7503
+ }
7504
+ out.push('항목을 건드리기 전에는 works(tenant, work, item) 로 이미 끝났는지 먼저 확인한다 — 전문은 get_case(tenant, item_key).');
7505
+ return out.join('\n');
7506
+ }
7507
+
5873
7508
  /*
5874
7509
  * ── `works` 한 줄의 「항목」 토막 (DESIGN-genetics-ssot.md §7.7) ───────────────────────────
5875
7510
  *
@@ -5902,7 +7537,7 @@ function worksItemsLine(items) {
5902
7537
  * **목록 밖으로 떨어진 결정**(2026-09-21) — 그 일의 갈래와 만나는데 **항목이 못 된** 장.
5903
7538
  *
5904
7539
  * ★ 왜 이 줄이 있나(LIM 실측 2026-09-21): 「간트에서 항목을 에이전트에게 시키기」 한 목표를
5905
- * 다룬 결정 6장 중 표지(`work:`)를 단 것은 2장뿐이었고 그 둘조차 열쇠가 달랐다. 나머지 4장은
7540
+ * 다룬 결정 5장 중 표지(`work:`)를 단 것은 1장(gantt-dispatch)뿐이었다. 나머지 4장은
5906
7541
  * **어느 목록에도 어느 수에도 안 섰다** — 그래서 사람도 두뇌도 「왜 이 일에 항목이 이것뿐인가」를
5907
7542
  * 물을 길이 없었다. 항목으로 세우지는 **않는다**(2026-08-31 오너 판정: 그 일에 **대한** 결정은
5908
7543
  * 그 일의 **할 일**이 아니다). 빠졌다는 **사실만** 말한다.
@@ -6382,12 +8017,48 @@ async function callTool(name, args = {}) {
6382
8017
  const result = await api('GET', taskReadRequest(name, args));
6383
8018
  return JSON.stringify(taskReadResult(name, args, result), null, 2);
6384
8019
  }
8020
+ case 'report_task': {
8021
+ const req = taskWriteRequest(args);
8022
+ const call = async (body) => {
8023
+ try {
8024
+ return taskWriteResult(args.task_uid, await api('POST', req.path, body));
8025
+ } catch (e) {
8026
+ /* 거절이어도 글은 남았을 수 있다(근거가 없어 검수로 못 간 경우) — 서버가 그 글을 400 봉투에 함께 실어 준다. 지어내지 않고 그대로 말한다. */
8027
+ if (e && e.status === 400 && e.why && /^(need_evidence|need_done_when|need_assignee)$/.test(e.why)) {
8028
+ return `«${args.task_uid}» — 글은 남았지만 검수로 못 갔다(${e.why}): ${e.message}`
8029
+ + (e.comment && e.comment.id ? ` 글은 남았습니다(#${e.comment.id}).` : '');
8030
+ }
8031
+ throw e;
8032
+ }
8033
+ };
8034
+ try {
8035
+ return await call(req.body);
8036
+ } catch (e) {
8037
+ /* 기본 근거(이 세션)는 내부층 갈래라 member 자격에서는 403 inner_only 로 **글도 안 남는다**(2026-09-26 검토). 사용자가 근거를 안 준 판이면
8038
+ 근거 없이 한 번 더 보낸다 — 그러면 글은 남고, 검수로 못 간 사실은 위 400 갈래가 말한다. 사용자가 직접 준 근거는 되풀이하지 않는다. */
8039
+ if (e && e.status === 403 && e.why === 'inner_only' && req.defaulted) {
8040
+ const out = await call({ ...req.body, evidence: [] });
8041
+ return out + ' (이 자격으로는 세션을 근거로 못 실어 근거 없이 남겼다 — 내부층 근거는 우리 팀만.)';
8042
+ }
8043
+ throw e;
8044
+ }
8045
+ }
6385
8046
  case 'works': {
6386
8047
  if (args.work) {
6387
- // 완료 확인/표시/해제(095) — claim 과 별개 통로. item 은 필수(어느 결정인지).
8048
+ // 완료 확인/표시/해제(095) — claim 과 별개 통로. 표시·해제는 item 이 필수(어느 결정인지).
6388
8049
  const wSlug = String(args.work);
6389
8050
  const item = args.item ? String(args.item) : '';
6390
- if (!item) return "완료 확인/표시/해제에는 item(item_key)이 있어야 한다 — work만으로는 어느 결정인지 모른다.";
8051
+ if (!item) {
8052
+ // work 만 주면 **그 일의 항목 목록**(2026-09-27) — 번호·제목·계획·상태를 한 번에 읽는다.
8053
+ // 오너: 「매번 너한테 일감 항목 번호 가져오라고 하는 것 부터가 문제」 — 결정 장을 한 장씩 열던 자리.
8054
+ if (args.undone || args.done_when || args.done_evidence) {
8055
+ return '완료 표시/해제에는 item(item_key)이 있어야 한다 — work만으로는 어느 결정인지 모른다. '
8056
+ + '항목 열쇠는 work 만 주고 불러 목록에서 고른다.';
8057
+ }
8058
+ const r = await api('GET', '/api/work/items?tenant=' + encodeURIComponent(args.tenant)
8059
+ + '&work=' + encodeURIComponent(wSlug));
8060
+ return worksItemsList(r);
8061
+ }
6391
8062
  if (args.undone) {
6392
8063
  const r = await api('POST', '/api/work/done', { tenant: args.tenant, work: wSlug, item, undone: true });
6393
8064
  return `${r.item.key} — 완료 표시를 되돌렸다(사후 정정, 원장에 남는다). 지금 상태: ${r.word}.`;
@@ -6489,6 +8160,10 @@ async function callTool(name, args = {}) {
6489
8160
  reopen: args.reopen || '', status: args.status || '', tags: args.tags || [],
6490
8161
  work: args.work || '', order: args.order || [], plan: args.plan || [],
6491
8162
  picks: Array.isArray(args.picks) ? args.picks : [], // 집은 자리(2026-09-03)
8163
+ // 반려 이름(2026-09-26) — 그대로 싣는다. 모양 검사·거절은 창구 하나(wiki_rejected_names_clean)가 한다.
8164
+ rejected_names: Array.isArray(args.rejected_names) ? args.rejected_names : [],
8165
+ // 옛 결정 뒤집기(2026-09-21) — 댈 때만 싣는다(안 대면 요청이 예전과 같다).
8166
+ ...(args.overturns ? { overturns: args.overturns, overturns_revision: args.overturns_revision || '' } : {}),
6492
8167
  // ★ 출처는 우리가 채운다 — 모델에게 안 묻는다(remember_case 와 같은 규율). 표지가
6493
8168
  // 열린 일과 만나 항목으로 설 때 「누가 심었나」(간트의 세션 자국)가 이것이다.
6494
8169
  ...myCitation(),
@@ -6499,6 +8174,16 @@ async function callTool(name, args = {}) {
6499
8174
  ? '원문은 저장됐지만 후속 정리가 재시도 한도에 닿았습니다. 운영 확인이 필요합니다.'
6500
8175
  : '원문은 저장됐고 후속 정리는 대기 중입니다. 같은 내용을 새로 저장하지 마세요.');
6501
8176
  if (Number(r.picks) > 0) lines.push(`집은 자리 ${r.picks}건 실림 — 다른 기계·다른 사람도 그 요소를 본다.`);
8177
+ // 반려 이름(2026-09-26) — 적힌 수와 빠진 이름을 그대로. 칸이 응답에 없으면 옛 서버다(조용히 버려졌다) — 그렇게 말한다.
8178
+ const sentRejected = Array.isArray(args.rejected_names) ? args.rejected_names.length : 0;
8179
+ if (typeof r.rejected_names === 'number') {
8180
+ if (r.rejected_names > 0) lines.push(`반려 이름 ${r.rejected_names}개를 적었다 — 이 소속처의 문서 발행·모의 발행·로컬 렌더가 경고로 센다(확인: list_rejected_names).`);
8181
+ if (Array.isArray(r.rejected_names_dropped) && r.rejected_names_dropped.length) {
8182
+ lines.push(`같은 제목으로 다시 적어 목록에서 빠진 반려 이름: ${r.rejected_names_dropped.join(' · ')} — 이제 세지 않는다. 뺄 뜻이 아니었으면 다시 적을 것.`);
8183
+ }
8184
+ } else if (sentRejected && !r.effects_pending) {
8185
+ lines.push('반려 이름을 보냈지만 이 서버는 아직 그 칸을 모른다 — 판이 낡았다(목록이 안 적혔다).');
8186
+ }
6502
8187
  // 안 적은 것을 **그렇게 말한다** — 조용히 열린 결정을 만들거나 계산 밖에 두지 않는다.
6503
8188
  if (!args.reopen) {
6504
8189
  lines.push('재개 조건을 안 적어 이 결정은 「아직 열린 것」으로 읽힌다. 끝난 결정이면 reopen 에 "조건 없음(종결)" 을 적을 것.');
@@ -6518,6 +8203,8 @@ async function callTool(name, args = {}) {
6518
8203
  // 표지(work:)의 앞날 — 문장의 정본은 서버(work_decl_mark_note → 응답 work_note)다.
6519
8204
  // 여기서 프로즈를 다시 지으면 두 벌이 되어 한쪽이 낡는다(2026-09-01 에 두 벌을 실제로
6520
8205
  // 걷었다 — mcp.php 와 이 파일이 같은 문장을 복제하고 있었다).
8206
+ // 옛 장 도장(2026-09-21) — 문장의 정본은 서버(wiki_decision_overturn_note → 응답 overturn_note)다. 옮기기만 한다.
8207
+ if (typeof r.overturn_note === 'string' && r.overturn_note !== '') lines.push(r.overturn_note);
6521
8208
  if (typeof r.work_note === 'string' && r.work_note !== '') {
6522
8209
  lines.push(r.work_note);
6523
8210
  } else if (r.work_mark) {
@@ -6529,6 +8216,28 @@ async function callTool(name, args = {}) {
6529
8216
  }
6530
8217
  return lines.join('\n');
6531
8218
  }
8219
+ case 'list_rejected_names': {
8220
+ // 고르는 자(이름마다 가장 새 결정 · 뒤집힘 제외 · 깨진 줄 skipped)는 서버 report_rejected_names 하나다 — 여기서 다시 고르지 않는다.
8221
+ const r = await api('GET', '/api/wiki/rejected_names?tenant=' + encodeURIComponent(args.tenant));
8222
+ const names = Array.isArray(r.names) ? r.names : [];
8223
+ const skipped = Array.isArray(r.skipped) ? r.skipped : [];
8224
+ if (args.format === 'json') {
8225
+ // 기계가 읽는 모양 — render.py·발행 직전 훅이 렌더러 cfg `rejected_names` 로 그대로 얹는다(대조에 쓰는 셋만).
8226
+ return JSON.stringify({
8227
+ tenant: r.tenant, tenant_slug: r.tenant_slug,
8228
+ names: names.map((n) => ({ name: n.name, use: n.use || '', near: Array.isArray(n.near) ? n.near : [] })),
8229
+ counted: names.length, skipped, note: r.note || '',
8230
+ });
8231
+ }
8232
+ const lines = [`${r.tenant} — 반려 이름 ${names.length}개(결정 장 ${r.decisions_read ?? 0}장을 읽음)`];
8233
+ for (const n of names) {
8234
+ const near = Array.isArray(n.near) && n.near.length ? ` | 함께: ${n.near.join(', ')}` : '';
8235
+ lines.push(` · ${n.name} → ${n.use || '(대신 쓸 이름 안 적힘)'}${near} (${n.since || '날짜 모름'} · ${n.decision}${n.status && n.status !== '유효' ? ' · ' + n.status : ''})`);
8236
+ }
8237
+ for (const s of skipped) lines.push(` ⚠ ${s.path} — 반려 이름 줄을 못 읽었다(${s.why}). 그 장의 이름은 세지 않는다.`);
8238
+ if (r.note) lines.push(r.note);
8239
+ return lines.join('\n');
8240
+ }
6532
8241
  case 'forget_decision': {
6533
8242
  const r = await api('DELETE', '/api/wiki/decision?tenant=' + encodeURIComponent(args.tenant)
6534
8243
  + '&path=' + encodeURIComponent(String(args.path || ''))
@@ -6561,7 +8270,7 @@ async function callTool(name, args = {}) {
6561
8270
  title: args.title, symptom: args.symptom, cause: args.cause || '', fix: args.fix,
6562
8271
  expected_revision: args.expected_revision, commit_id: args.commit_id,
6563
8272
  evidence: args.evidence || '', tags: args.tags || [],
6564
- paths: args.paths || [], commands: args.commands || [], errors: args.errors || [],
8273
+ paths: args.paths || [], commands: args.commands || [], errors: args.errors || [], work: args.work || '',
6565
8274
  picks: Array.isArray(args.picks) ? args.picks : [], // 집은 자리(2026-09-03) — 그대로 넘긴다, 모양은 서버가 검사
6566
8275
  // ★ **출처는 우리가 채운다 — 모델에게 안 묻는다.** 스키마에 노출하면 지어낸다
6567
8276
  // (DESIGN-session-as-vcs.md §5.1 「출처를 도구 인자로 두지 말 것」). 모르면 안 보내고, 서버는 안 적는다.
@@ -6586,6 +8295,12 @@ async function callTool(name, args = {}) {
6586
8295
  }
6587
8296
  if (Array.isArray(r.skipped) && r.skipped.length) lines.push('못 보낸 곳: ' + r.skipped.join(' / '));
6588
8297
  }
8298
+ // 일의 열쇠 — **적혔는지 부른 쪽이 알아야 한다.** 옛 서버는 이 칸 자체가 없어 조용히 버리므로 그것을 그대로 말한다.
8299
+ if (args.work) {
8300
+ if (r.work === undefined) lines.push(`⚠ work=「${args.work}」 는 **이 서버가 모르는 칸**이라 안 적혔다 — 서버를 먼저 올린 뒤 다시 적는다.`);
8301
+ else if (!r.work) lines.push(`⚠ work=「${args.work}」 가 안 적혔다 — 서버가 받아들이지 않았다.`);
8302
+ else lines.push(`일: ${r.work} — 이 사례가 그 일의 시정조치 기록으로 걸렸다(갈래 맨 앞).`);
8303
+ }
6589
8304
  if (r.triggers && typeof r.triggers === 'object') {
6590
8305
  const t = r.triggers;
6591
8306
  const n = (t.paths?.length || 0) + (t.commands?.length || 0) + (t.errors?.length || 0);
@@ -6741,8 +8456,46 @@ async function callTool(name, args = {}) {
6741
8456
  + (follow.length ? `\n\n${follow.join('\n')}` : '');
6742
8457
  }
6743
8458
  case 'push_session': {
6744
- const { r, before, parts } = await pushSession(args.tenant, args.uuid, args.title, args.dry_run, 'propose');
6745
- const cut = `${before.toLocaleString()}B → ${r.bytes.toLocaleString()}B`;
8459
+ /*
8460
+ * ★ **사람의 올리기도 입구 임대를 지난다 — 간격은 안 지난다**(DESIGN-session-delta-sync §3.4 · C7). 같은 대화를 이 기계의
8461
+ * 다른 창이 지금 올리고 있으면 **비동기로** 최대 30초 기다린다(`Atomics.wait` 는 stdio 고리를 통째로 멈춰 안 쓴다 — race S5).
8462
+ * 정합성은 어차피 서버가 지킨다 — 기다리는 것은 헛 409 를 줄이는 효율 장치라, 임대 자리를 못 만들면 임대 없이 간다.
8463
+ * dry_run 은 대응표·키를 안 만들고 서버도 행을 안 써 임대를 안 탄다.
8464
+ */
8465
+ let lease = null;
8466
+ if (!args.dry_run) {
8467
+ const got = await leaseWait(String(args.uuid || ''));
8468
+ if (got.held) {
8469
+ return `다른 창이 올리는 중입니다 — 이 기계의 다른 창이 이 대화(${String(args.uuid).slice(0, 8)})를 지금 올리고 있어 `
8470
+ + `${Math.round(HUMAN_LEASE_WAIT_MS / 1000)}초를 기다렸지만 끝나지 않았습니다. 잠시 뒤 다시 부르세요.`;
8471
+ }
8472
+ lease = got.h || null;
8473
+ }
8474
+ let pushed;
8475
+ try {
8476
+ /*
8477
+ * ★ **사람의 올리기도 덧붙임 길을 탄다**(§3.4 · C8) — 대응표가 서 있고 서버가 광고했으면 덧붙은 줄만 간다.
8478
+ * ⚠ 사람의 push 는 **FULL 예산을 세지도 않고 받지도 않는다**(`hooks.plan` 을 안 건다) — 예산은 기계의 폭주를
8479
+ * 막는 것이지 사람이 「지금 올려라」고 한 것을 미루는 장치가 아니다(C7 이 남긴 숙제 ②의 처방).
8480
+ * ⚠ 따라잡기도 여기서 돈다 — 옆 창이 더 뒤까지 올린 직후에 사람이 보던 409 `behind` 가 이 커밋에서 닫힌다(C6 넘김).
8481
+ */
8482
+ pushed = await pushSession(args.tenant, args.uuid, args.title, args.dry_run, 'propose', null,
8483
+ lease ? { progress: () => leaseTouch(lease) } : null);
8484
+ // 사람이 올린 시각도 간격의 시작이다 — 방금 올린 대화를 옆 창의 자동 길이 곧바로 또 보내지 않게(색인은 기계 공용).
8485
+ if (!args.dry_run) indexPatch(String(args.uuid), { auto: { last_at: Date.now() } });
8486
+ } catch (e) {
8487
+ // 따라잡고 보니 보낼 줄이 없었다 — 오류가 아니라 사실이다(대응표는 섰다).
8488
+ if (String(e?.code || '') === 'caught_up') {
8489
+ return `올릴 것이 없습니다 — 서버가 이미 이 대화(${String(args.uuid).slice(0, 8)})의 끝까지 가지고 있습니다.`
8490
+ + (e?.moved && !args.dry_run ? '\n이 기계의 좌표만 맞췄습니다(다음 갱신부터 덧붙은 줄만 올라갑니다).' : '');
8491
+ }
8492
+ throw e;
8493
+ } finally { fileUnlock(lease); }
8494
+ const { r, before, parts, plan, appended } = pushed;
8495
+ // 표시를 **덧붙임 X B / 전체 Y B** 로(§3.4) — 통째로 갔으면 전처럼 「전 → 후」다.
8496
+ const cut = plan?.mode === 'APPEND' || plan?.mode === 'ASK'
8497
+ ? `덧붙임 ${Number(appended || 0).toLocaleString()}B / 전체 ${r.bytes.toLocaleString()}B`
8498
+ : `${before.toLocaleString()}B → ${r.bytes.toLocaleString()}B`;
6746
8499
  // 폴더 제안의 결과 — 문장의 정본은 서버다(§4.2). 옛 서버는 칸이 없어 침묵한다.
6747
8500
  const bindNote = typeof r.binding?.note === 'string' && r.binding.note !== '' ? r.binding.note + '\n' : '';
6748
8501
  // 조각으로 갔으면 말한다 — 크기 때문에 사람이 할 일이 없다는 것을 결과가 보여 준다.
@@ -7165,10 +8918,36 @@ function cellLinkDir(pathEnv, candidates, exists, writable) {
7165
8918
  }
7166
8919
  return null;
7167
8920
  }
8921
+ /*
8922
+ * ★★ **남의 HOME 으로 뜬 프로세스는 전역 PATH 자리를 안 건드린다**(C7 · 2026-09-21 실측 누수).
8923
+ * `CELL_PATH` 는 `os.homedir()` 를 따른다(posix 는 HOME 환경). 시험이 가짜 HOME 으로 MCP 를 띄우면 그 HOME 의 세포가
8924
+ * 생기는데, 전역 자리(/opt/homebrew/bin 등)의 `gcell` 이 「옛 자리를 가리키는 우리 심링크」로 읽혀 **가짜 HOME 쪽으로
8925
+ * 갈아 끼워졌다** — 시험이 끝나 그 HOME 이 지워지면 이 기계의 `gcell` 이 통째로 dangling 이 된다(C6 검토 때 실제로 났다).
8926
+ * 그래서: 고른 자리가 이 HOME 밖(전역)이고, 이 프로세스의 HOME 이 **이 사용자의 실제 집**(passwd — `os.userInfo().homedir`)이
8927
+ * 아니면 걸지도 걷지도 않고 말만 한다. HOME 안의 자리(~/.local/bin)는 그 HOME 의 것이라 그대로 건다.
8928
+ * 실제 집을 모르면(userInfo 가 던짐) 닫는 쪽 — 전역 자리를 안 건드린다. **순수 함수**(smoke 가 표로 잰다).
8929
+ */
8930
+ function cellLinkForeign(dir, home, realHome) {
8931
+ const norm = (p) => { const s = String(p || ''); try { return fs.realpathSync(s); } catch { return s; } };
8932
+ const h = norm(home);
8933
+ const d = norm(dir);
8934
+ if (h && (d === h || d.startsWith(h.endsWith(path.sep) ? h : h + path.sep))) return false; // 이 HOME 의 자리
8935
+ return !realHome || norm(realHome) !== h;
8936
+ }
8937
+ function cellRealHome() {
8938
+ try { return os.userInfo().homedir || ''; } catch { return ''; }
8939
+ }
7168
8940
  function cellLinkOnPath() {
7169
8941
  const dir = cellLinkDir(process.env.PATH, CELL_LINK_CANDIDATES,
7170
8942
  (d) => { try { return fs.statSync(d).isDirectory(); } catch { return false; } },
7171
8943
  (d) => { try { fs.accessSync(d, fs.constants.W_OK); return true; } catch { return false; } });
8944
+ if (dir && cellLinkForeign(dir, os.homedir(), cellRealHome())) {
8945
+ // ⚠ 까닭을 **그대로** 적는다(반박 compat 2-B) — 「시험용 HOME」이라고만 하면 `sudo -E`·passwd 항목이 없는 컨테이너처럼
8946
+ // HOME 이 정당하게 다른 사람이 원인을 못 찾는다. 그 사람들은 이 한 줄을 보고 손으로 걸면 된다.
8947
+ return `전역 자리(${dir})에는 \`gcell\` 을 안 걸었다 — 이 프로세스의 HOME(${os.homedir()})이 passwd 의 집`
8948
+ + `(${cellRealHome() || '알 수 없음'})과 달라, 남의 HOME 으로 뜬 프로세스가 기계 공용 자리를 갈아 끼우지 않게 막았다`
8949
+ + `(시험용 HOME·sudo -E·컨테이너). 전체 경로 ${CELL_PATH} 로 부르거나, 직접 걸려면 ln -sfn ${CELL_PATH} ${path.join(dir, 'gcell')}.`;
8950
+ }
7172
8951
  if (!dir) {
7173
8952
  if (process.platform === 'win32') {
7174
8953
  return `PATH 에 안 걸었다(Windows 는 심링크에 권한이 필요하다) — 전체 경로 ${CELL_PATH} 로 부르거나 `
@@ -7193,6 +8972,7 @@ function cellLinkOnPath() {
7193
8972
  function cellUnlinkOnPath() {
7194
8973
  const out = [];
7195
8974
  for (const d of CELL_LINK_CANDIDATES) {
8975
+ if (cellLinkForeign(d, os.homedir(), cellRealHome())) continue; // 거는 쪽과 같은 가드 — 남의 HOME 은 전역 자리를 안 만진다
7196
8976
  const link = path.join(d, 'gcell');
7197
8977
  try {
7198
8978
  if (fs.lstatSync(link).isSymbolicLink() && fs.readlinkSync(link) === CELL_PATH) {
@@ -7631,7 +9411,7 @@ if (IS_MAIN) {
7631
9411
  }
7632
9412
 
7633
9413
  // 시험용 — 서버 동작에는 쓰이지 않는다. materializeAssets 만 네트워크를 쓴다.
7634
- export { taskReadTools, taskReadRequest, taskReadResult,
9414
+ export { videoThumbs, taskReadTools, taskReadRequest, taskReadResult,
7635
9415
  encodeAsset, imageDims, imageHostAllowed, materializeAssets, warnText, IMAGE_HOST_ALLOW, HOST_ALLOW,
7636
9416
  scrubTranscript, splitAtLines, PUSH_PART_MAX,
7637
9417
  // DESIGN-genetics-ssot.md §3·§5 — 색인·잠금·폴더 identity·알림 문구의 순수/파일 함수(시험이 걷는다)
@@ -7652,4 +9432,16 @@ export { taskReadTools, taskReadRequest, taskReadResult,
7652
9432
  autoRowOnServer,
7653
9433
  // 2026-09-18 Windows — 남의 규칙(Claude Code 슬러그)·플랫폼 갈래는 시험이 표로 걷는다
7654
9434
  transcriptSlug, transcriptDir, cursorUserRoot, sqliteBin, npxCacheResetHint, cellPlatformSupported,
7655
- HOLDS_FILE, UPDATE_BUDGET_MS };
9435
+ HOLDS_FILE, UPDATE_BUDGET_MS,
9436
+ // DESIGN-session-delta-sync C6 — 규칙판·좌표·기계 키·대응표·머리 끊기·private 판정(시험이 걷는다)
9437
+ SCRUB_REV, CWD_HIST_MAX, PUSH_MAC_WIN, pushDir, pushKey, pushKeyId, pushMac, pushCoords,
9438
+ ledgerRead, ledgerWrite, ledgerAfterFull, autoPrepare, transcriptHead,
9439
+ // DESIGN-session-delta-sync C7 — 한 입구(갈래·임대·임대 안 판정·닫힘) · 요청 상한(바이트에 비례) · 전역 링크 가드(시험이 걷는다)
9440
+ autoPolicyOf, autoIntervalOf, autoGateOf, autoBlockedFor, autoLegacyCheck, autoHistOverflow, gitRootAbove,
9441
+ leaseFile, leaseTake, LEASE_QUIET_MS, HUMAN_LEASE_WAIT_MS, API_TIMEOUT_MS, apiTimeoutFor, API_MIN_BPS, AUTO_CLOSED_CODES,
9442
+ AUTO_BACKOFF_LONG_MS, AUTO_PUSH_MIN_MS, cellLinkForeign,
9443
+ // DESIGN-session-delta-sync C8 — 갈래 고르기·능력 광고·조각 나누기·접기·FULL 예산·사본 재정의(시험이 걷는다)
9444
+ planPush, pushSession, pushAppend, capsNote, capsAppend, capsScrubRev, capsFile, capsAppendGone, CAPS_FALSE_TTL_MS,
9445
+ readAt, lastNewlineAt, countLines, appendScan, splitSeg, foldHist, scrubDelta, hashRange, pushTenantId,
9446
+ APPEND_ENV_MAX, APPEND_RAW_STEP, APPEND_ROUND_MAX,
9447
+ autoFullBudgetOf, autoFullCounts, autoDupOf, PUSH_FULL_BUDGET, PUSH_FULL_WINDOW_MS, PUSH_FULL_REBASE, PUSH_FULL_LOG_MAX };