From f306031f03c1857226adfdb7b0130de792fcacae Mon Sep 17 00:00:00 2001 From: fwkyle Date: Sat, 17 Oct 2026 20:25:04 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=EB=A1=9C=ED=8B=B0=20=EB=B0=94?= =?UTF-8?q?=EB=8B=A5=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0,=20?= =?UTF-8?q?=EB=A1=9C=ED=8B=B0=EB=8A=94=20=EC=B0=BD=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=EB=A7=8C=20(KADAN=5FFLOOR=3Dtmux|herdr)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit floor-rottie.mjs를 rottie-cli.mjs로 줄여 창·시니어 탭·adopt가 함께 쓰는 함수만 남기고 바닥 전용 코드(rottieFloor, sendRottieText, buildRottieFloorCreateArgv 등)를 지운다. KADAN_FLOOR=rottie는 KADAN_FLOOR_INVALID. 옛 원장 floor:rottie 줄은 그대로 읽힌다. docs/watch-overview.md에 #181 D1(원장에 있는 요청 ID 우선) 예외를 더한다. Co-Authored-By: Claude Sonnet 5.5 --- CONTRIBUTING.md | 2 +- README.md | 4 +- docs/architecture.md | 4 +- docs/card-worktime.md | 2 +- docs/commands.md | 4 +- docs/design.md | 28 +- docs/kadan-guide.html | 12 +- docs/senior.md | 2 +- docs/watch-overview.md | 2 +- src/adopt-process.mjs | 2 +- src/cli-start-support.mjs | 6 +- src/cli.mjs | 2 - src/commands/inspect.mjs | 2 +- src/commands/restore.mjs | 4 +- src/commands/start.mjs | 17 +- src/commands/status.mjs | 4 +- src/commands/watch.mjs | 2 +- src/floor-rottie.mjs | 409 ------------------ src/floor-tmux.mjs | 2 +- src/floor.mjs | 4 +- src/rottie-cli.mjs | 106 +++++ src/senior-tab.mjs | 4 +- tests/adopt.test.mjs | 4 +- ...e-floor.test.mjs => floor-common.test.mjs} | 141 +----- tests/floor-rottie-removed.test.mjs | 40 ++ tests/herdr-floor.test.mjs | 4 +- tests/hidden-start-policy.test.mjs | 3 +- tests/live-floor-guard.test.mjs | 8 +- tests/rottie-list.test.mjs | 73 ---- tests/senior-tab.test.mjs | 7 +- tests/unit.test.mjs | 30 +- 31 files changed, 219 insertions(+), 715 deletions(-) delete mode 100644 src/floor-rottie.mjs create mode 100644 src/rottie-cli.mjs rename tests/{rottie-floor.test.mjs => floor-common.test.mjs} (63%) create mode 100644 tests/floor-rottie-removed.test.mjs delete mode 100644 tests/rottie-list.test.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c7b4197..d3b737ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,7 @@ This repository intentionally ships no `AGENTS.md` / `CLAUDE.md`. If you work wi ## Layout -- `src/` — CLI (`cli.mjs`), floors (`floor-tmux.mjs`, `floor-rottie.mjs`), ledger/storage, watch, dashboards. +- `src/` — CLI (`cli.mjs`), floors (`floor-tmux.mjs`, `floor-herdr.mjs`), ledger/storage, watch, dashboards. - `tests/` — run with `npm test` (`tests/run.mjs` sets an isolated tmux socket, no window, notifications off, then runs `node --test`). Do not run `node --test` bare. Tests that need tmux skip when it is absent. - `scripts/watch-judge.sh` — example judge command for `kadan watch`; requires `KADAN_JUDGE_MODEL`. - `skills/` — agent skills that drive Kadan (conductor, supervisor, secretary). diff --git a/README.md b/README.md index 156b02a2..11ab0408 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ kadan attach 비서 # 비서 터미널에 들어가 대화 시작 | 개념 | 한 줄 설명 | |---|---| | **역할** | `kadan-<이름>` tmux 세션 하나. 예: `비서`, `감독`, `작업자-a` | -| **바닥(floor)** | 역할이 실제로 사는 터미널 층. 기본은 `tmux`, macOS 앱인 `rottie`도 지원. `herdr`는 명시적으로 선택하는 시험 기능 | +| **바닥(floor)** | 역할이 실제로 사는 터미널 층. 기본은 `tmux`. `herdr`는 명시적으로 선택하는 시험 기능. macOS 앱 `rottie`는 바닥이 아니라 창(`KADAN_WINDOW=rottie`)으로만 쓴다 | | **원장(ledger)** | 시작·발령·우편·완료 같은 사건을 뒤에 덧붙이기만 하는 기록. 고치거나 지우지 않음 | | **완료 마커** | 에이전트가 직접 출력하는 `KADAN:DONE <카드id> ` 한 줄. 카단은 출력을 보고 완료를 짐작하지 않음 | | **워크 / 카드** | 워크 = 약속한 결과 한 건, 카드 = 그 결과를 위해 한 담당에게 맡긴 일 한 건 | @@ -117,7 +117,7 @@ kadan stop worker-a # 카단이 만든 세션만 끌 수 | 변수 | 뜻 | 기본값 | |---|---|---| | `KADAN_HOME` | 데이터 폴더 | `~/.kadan` | -| `KADAN_FLOOR` | 실행 바닥 (`tmux` \| `rottie` \| 시험 기능 `herdr`) | `tmux` | +| `KADAN_FLOOR` | 실행 바닥 (`tmux` \| 시험 기능 `herdr`) | `tmux` | | `KADAN_HERDR_SOCKET` | herdr 시험 서버의 절대 소켓 경로. 별도 `KADAN_HOME`과 함께 지정 | 없음 | | `KADAN_WINDOW` | 시작할 때 열 창 (`none` \| `rottie` \| `orca`). 비우면 `kadan window set`으로 고정한 기계 설정을 따른다 | `none` | | `KADAN_SOCKET` | tmux 구획 이름 | `kadan` | diff --git a/docs/architecture.md b/docs/architecture.md index 2c74a1dc..5dea9b8a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ flowchart TB USER[사용자와 감독] --> CLI[카단 CLI] CLI --> WORK[업무 카드와 실행 카드] CLI --> MAIL[우편과 역할별 inbox] - CLI --> FLOOR[실행 바닥: tmux 또는 rottie] + CLI --> FLOOR[실행 바닥: tmux, 시험용 herdr] FLOOR --> ROLE[대화형 AI 역할 세션] WORK --> STORE[(같은 SQLite: 사건과 상태 이력)] MAIL --> STORE @@ -43,7 +43,7 @@ flowchart TB | 우편 | 누구에게 무슨 말을 했고 읽음·답변은 어떤 상태인가? | `mail/events.jsonl`과 본문 파일 | | 역할별 우편함(inbox) | 내가 받은 편지·보낸 편지·답할 질문은 무엇인가? | 우편 원장의 역할별 조회. 역할마다 새 DB를 만들지 않음 | | 시스템 사건 원장 | 어떤 세션이 시작·종료됐고 감시가 무엇을 했는가? | `system/events.jsonl` | -| 실행 바닥 | 어느 터미널에 입력하고 화면을 읽는가? | tmux 또는 rottie의 실제 세션과 PID | +| 실행 바닥 | 어느 터미널에 입력하고 화면을 읽는가? | tmux(시험용은 herdr)의 실제 세션과 PID | **공통 실행 원장은 실행 카드에 연결되는 사건 이력이다.** 별도의 업무 관리 체계를 하나 더 만드는 것이 아니다. 카드 상태 이력은 약속과 진행을, 공통 사건은 발령·실행 종료를 맡는다. 카드에 연결된 일반 질문도 새 실행은 아니다. diff --git a/docs/card-worktime.md b/docs/card-worktime.md index 515af00d..992682b8 100644 --- a/docs/card-worktime.md +++ b/docs/card-worktime.md @@ -41,7 +41,7 @@ | `restarted` | 같은 세대 안에서 다른 모델·강도로 다시 시작됐다. 시각만으로 어느 쪽인지 단정하지 않는다. **모델·강도가 같으면 실행 도구(omo·codex 등) 이름만 달라도 재시작으로 보지 않고 알려진 모델로 판정한다** | | `no-model` | 실행 명령에서 모델을 읽을 수 없다 | -- 아직 살아 있는 세대(stop 없음)는 옛 세션 발령 막기와 같이 "지금 pane PID와 같은 start만" 본다. 읽기 전용 계산에서 바닥(tmux·rottie)을 조회하지 않으므로, 세대의 마지막 실행 명령 기록의 PID를 지금 PID로 본다. +- 아직 살아 있는 세대(stop 없음)는 옛 세션 발령 막기와 같이 "지금 pane PID와 같은 start만" 본다. 읽기 전용 계산에서 바닥(tmux)을 조회하지 않으므로, 세대의 마지막 실행 명령 기록의 PID를 지금 PID로 본다. - **한계**: 원장이 아는 것은 세션을 띄울 때의 명령뿐이다. 세션 안에서 나중에 바꾼 모델(`/model` 등)은 알 수 없다. - 역할 프로필(작업자·검수자)은 start 기록의 `roleProfile`이 기준이고, 없으면 카드 단계로 추론한다(review는 검수자, 그 밖은 작업자). diff --git a/docs/commands.md b/docs/commands.md index f49f656c..e8063e9b 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -17,7 +17,7 @@ | 변수 | 뜻 | |---|---| | `KADAN_HOME` | 원장·카드·설정이 사는 폴더. 기본 `~/.kadan` (옛 이름 `KADAN_LITE_HOME`도 읽음) | -| `KADAN_FLOOR` | 에이전트가 사는 바닥. `tmux`(기본) 또는 `rottie`. 시험 기능 `herdr`는 별도 `KADAN_HOME`으로 명시 선택하며, 실행 중 전환·자동 폴백 없음 | +| `KADAN_FLOOR` | 에이전트가 사는 바닥. `tmux`(기본). 로티는 바닥이 아니라 창(`KADAN_WINDOW=rottie`)이라 `rottie`를 주면 `KADAN_FLOOR_INVALID`다. 시험 기능 `herdr`는 별도 `KADAN_HOME`으로 명시 선택하며, 실행 중 전환·자동 폴백 없음 | | `KADAN_HERDR_SOCKET` | herdr 시험 기능의 named server 절대 소켓 경로. `KADAN_HOME`과 함께 필수. 서버 자동 기동·훅 설치 없음. [실행 방법](experiments/herdr-roundtrip.md) | | `KADAN_WINDOW` | tmux 바닥에서 시작할 때 여는 창. `none`(기본) · `rottie` · `orca`. 있으면 기계 설정(`kadan window`)보다 먼저다 | | `KADAN_ROTTIE_BIN` | `KADAN_WINDOW=rottie`일 때 말 걸 로티 CLI의 절대경로. 있으면 기계 설정의 경로보다 먼저다 | @@ -89,7 +89,7 @@ tmux 세션을 종료한 뒤 원장의 최신 탭 ID를 조회하고, `terminal 로티 데몬을 다시 켜면 tmux 세션(에이전트)은 살고, 그 세션을 보여 주던 로티 탭만 끊긴다. `restore`는 탭만 다시 붙인다. -- 바닥이 tmux이고 `KADAN_WINDOW=rottie`일 때만 쓴다. 로티 바닥은 데몬과 함께 에이전트도 끝나므로 되살릴 것이 없다. +- 바닥이 tmux이고 `KADAN_WINDOW=rottie`일 때만 쓴다. - 세션마다 원장에 적힌 로티 탭 상태를 로티에 묻는다. `running`·`created`면 건너뛰고, `unknown`·`closed`·`exited`이거나 탭이 없으면 새 탭을 붙인다. - 마지막 `start`에 로티 탭이 없는 세션(숨김으로 띄운 watch·대시보드)은 건너뛴다. - 탭 조회가 하나라도 실패하면 아무것도 하지 않고 이유를 보고한다. diff --git a/docs/design.md b/docs/design.md index 1ea1a9cf..17347b46 100644 --- a/docs/design.md +++ b/docs/design.md @@ -21,8 +21,8 @@ ## 정체성 - 이 저장소는 카단(kadan)의 심플 코어다. 이전 카단 구현(관제층)은 동결 보존하며 이 저장소가 대체하려는 것이 아니다. -- **tmux는 지금의 바닥이지, 영원한 바닥이 아니다.** 나중에 로티 같은 다른 바닥으로 - 이동할 수 있다. 중요한 것은 바닥이 아니라 코어다 — 원장·안전장치·명령 인터페이스 +- **tmux는 지금의 바닥이지, 영원한 바닥이 아니다.** 나중에 다른 바닥으로 + 이동할 수 있다. 로티는 바닥이 아니라 창(`KADAN_WINDOW=rottie`)으로만 쓴다. 중요한 것은 바닥이 아니라 코어다 — 원장·안전장치·명령 인터페이스 (`start/send/done/wait/read/status/log/attach/stop`)·DONE 마커 규약은 바닥과 분리해 유지하고, 바닥을 바꿀 때 코어는 그대로 갖고 간다. - 실행 도구(provider) 어댑터를 만들지 않는다. 어떤 CLI든 바닥의 세션 안에 띄우면 @@ -44,26 +44,20 @@ tmux는 (a)(b)(c)를 충족했고 (d)는 Mac에서 통과했다(서버 왕복은 서버가 생기면). -**제품판 바닥(rottie)** — GUI 제품이라 (a)(b)(c)는 설계상 해당 없다. 자격은 -(d) 12장 왕복 + 아래 전제를 문서에 명시하는 것이다. +**로티는 바닥이 아니다(2026-10-17 제거).** 로티 바닥(`KADAN_FLOOR=rottie`)은 쓰지 않아 코드·시험을 +없앴고, `KADAN_FLOOR=rottie`는 `KADAN_FLOOR_INVALID`로 거절한다. 로티는 tmux 역할을 보여 주는 +창으로만 쓴다(`KADAN_WINDOW=rottie`, 시니어 탭, adopt 탭). 9/15 이전 원장의 `floor:"rottie"` 시작 줄은 +그대로 읽힌다. -- 앱이 떠 있어야 한다(`ROTTIE_APP_NOT_RUNNING`은 typed로 그대로 보고, 자동 기동 없음). -- 빌드마다 소켓·데이터·데몬이 따로다. 실측 대상은 - Rottie dev 빌드 실행 파일(`KADAN_ROTTIE_BIN`)이고, start 원장에 create 응답의 - `runtime.build`를 `rottieBuild`로 적으며, 빌드가 다른 앱을 한 원장에 섞지 않는다. -- Cmd+Q는 종료 팝업대로 터미널을 닫는다. 데몬 PTY 생존은 강제 종료·안전 재시작 경로에서만 - 성립한다(card-18 11번). - -**등록 현황(2026-08-30 관리자 등록):** tmux(명세·서버), rottie(제품판). rottie 등록 -근거는 2026-08-30 12장 왕복 표(Local build `8938ad06`, 관리자 작업장 기록)다. dev 빌드 재검증(card-19)과 일회성 `ROTTIE_INTERNAL` 재현은 남은 과제다. +**등록 현황:** tmux(명세·서버). herdr은 시험 바닥이다. ### 바닥 병존 조건 (4개) - 바닥 = 파일 하나 + 함수 7개 + 선택 1개. 기계당 하나, `KADAN_FLOOR`로 설치 때 고정한다. - 런타임 전환·자동 폴백·capability 등급은 만들지 않는다. -- 두 바닥이 병존하려면 둘 다 같은 12장 왕복을 통과해야 한다. -- 정식 등록 바닥은 tmux·rottie 둘이다. 2026-10-11 사용자 승인으로 [herdr 시험 바닥](experiments/herdr-roundtrip.md)을 선택 기능으로 제공한다. 별도 데이터 폴더와 named server 소켓을 명시하며, 정식 등록·기계 기본값 변경을 뜻하지 않는다. +- 바닥이 둘 이상 병존하려면 둘 다 같은 12장 왕복을 통과해야 한다. +- 정식 등록 바닥은 tmux 하나다. 2026-10-11 사용자 승인으로 [herdr 시험 바닥](experiments/herdr-roundtrip.md)을 선택 기능으로 제공한다. 별도 데이터 폴더와 named server 소켓을 명시하며, 정식 등록·기계 기본값 변경을 뜻하지 않는다. ## 잠긴 최소 규칙 (이것만은 지킨다) @@ -96,7 +90,7 @@ tmux는 (a)(b)(c)를 충족했고 (d)는 Mac에서 통과했다(서버 왕복은 그래서 활동이 붙은 시각보다 뒤일 때(`activity > created`)만 사람 입력으로 센다. 로티 탭이 시작과 동시에 붙어도 첫 안내·발령이 막히지 않는다. 한계: 붙은 바로 그 초 안에 누른 키는 놓친다. 반대로 붙은 뒤 터미널 응답 같은 입력으로 활동이 바뀌면 그 창은 30초 보류된다(막히는 것은 시작 첫 안내뿐이고 start는 성공). 이는 화면에 보이는 확인이 아니며 AI 인증·모델 응답 성공을 증명하지 않는다. 복사모드 검사는 기존 공통 전송 경계를 재사용한다. - 이 tmux 프로세스·입력 검증은 Rottie 바닥의 실측을 대신하지 않는다. + 이 tmux 프로세스·입력 검증은 다른 바닥의 실측을 대신하지 않는다. 실제 전경 AI 프로세스의 명시 모델도 시작 기록과 대조한다. 불일치·미확인이면 거부한다. 빈 셸·비AI 명령·모델 미지정·이전 세대 신원만 남은 pane에는 카드를 붙여넣지 않는다 — 일반 우편·질문·답장은 이 경계를 거치지 않는다(2026-09-21 --profile @@ -177,7 +171,7 @@ tmux는 (a)(b)(c)를 충족했고 (d)는 Mac에서 통과했다(서버 왕복은 - 코어(core): 바닥이 바뀌어도 갖고 가는 것 — 원장, 안전장치(생존 확인·모호하면 판정 금지· 자기 것만 종료), 명령 인터페이스(start/send/done/wait/read/status/log/attach/stop), DONE 마커 규약. -- 바닥(bottom): 에이전트가 사는 곳. 지금은 tmux 구획(`-L kadan`). 로티 같은 다른 바닥으로 +- 바닥(bottom): 에이전트가 사는 곳. 지금은 tmux 구획(`-L kadan`). 다른 바닥으로 이동할 수 있다. - 역할(role): 슈퍼감독·일반감독·작업자 등 바닥의 세션 하나로 띄우는 단위. - 원장(ledger): 무엇을 했는지 뒤에만 추가하는 사건 기록. 현재 실제 저장은 SQLite다. diff --git a/docs/kadan-guide.html b/docs/kadan-guide.html index c24e269d..285add5a 100644 --- a/docs/kadan-guide.html +++ b/docs/kadan-guide.html @@ -355,9 +355,9 @@

"다 했어요"를 어떻게 믿을까요?

장면 5 · 비교

바닥이 바뀌면 카단도 바뀔까요?

-

AI가 사는 곳(바닥)은 tmux에서 로티(rottie) 같은 다른 곳으로 바뀔 수 있어요. 하지만 원장·검문소·DONE 마커·명령(코어)은 그대로 두고 바닥 조각만 갈아끼워요.

+

AI가 사는 곳(바닥)은 tmux에서 herdr(시험용) 같은 다른 곳으로 바뀔 수 있어요. 하지만 원장·검문소·DONE 마커·명령(코어)은 그대로 두고 바닥 조각만 갈아끼워요.

- + @@ -375,7 +375,7 @@

바닥이 바뀌면 카단도 바뀔까요?

바닥만 갈아끼운다 - KADAN_FLOOR=tmux | rottie (floor.mjs:4) + KADAN_FLOOR=tmux | herdr (floor.mjs:4) 코어 — 바뀌지 않는 것 원장(ledger) @@ -385,11 +385,11 @@

바닥이 바뀌면 카단도 바뀔까요?

start · send · done · wait · read · status · log · attach · stop - 바닥 조각: rottie - 제품 창 — 앱이 떠 있어야 한다 + 바닥 조각: herdr + 시험 바닥 — 별도 소켓·데이터 폴더 바닥이 해 주는 일은 같다: 만들기·살아있나·PID·밀어넣기·읽기·종료·목록 (+변화 대기) — floor-tmux.mjs:596 -
코어(원장·검문소·DONE 마커·명령)는 같은 색 그대로, 바닥 조각만 tmux에서 rottie로 바뀐다. (출처: src/floor.mjs:4, src/floor-tmux.mjs:596)
+
코어(원장·검문소·DONE 마커·명령)는 같은 색 그대로, 바닥 조각만 tmux에서 herdr로 바뀐다. (출처: src/floor.mjs:4, src/floor-tmux.mjs:596)
diff --git a/docs/senior.md b/docs/senior.md index 7dff2d3d..312e48d1 100644 --- a/docs/senior.md +++ b/docs/senior.md @@ -68,7 +68,7 @@ Why: kyle이 시니어가 무엇을 읽고 어떻게 고민하는지 화면에 ### 알려진 한계 -- **로티 실행 파일 첫 인자 가드**: 로티 실행 파일을 첫 인자가 `status|workspace|terminal|recovery`가 아닌 꼴(인자 없음·`--version`·오타)로 부르면 CLI가 아니라 앱 전체가 다시 떠서 약 3초 뒤 기존 창을 앞으로 가져온다(2026-10-16 로티 쪽 실측). `terminal create`·`workspace add` 자체는 맨 앞 앱·활성 탭을 바꾸지 않아 포커스 없는 생성 옵션은 필요 없다. 그래서 `runRottie`(`src/floor-rottie.mjs`)는 주입 spawn이어도 첫 인자를 먼저 검사해 `ROTTIE_ARGV_REJECTED`(effect none)로 실행하지 않는다. +- **로티 실행 파일 첫 인자 가드**: 로티 실행 파일을 첫 인자가 `status|workspace|terminal|recovery`가 아닌 꼴(인자 없음·`--version`·오타)로 부르면 CLI가 아니라 앱 전체가 다시 떠서 약 3초 뒤 기존 창을 앞으로 가져온다(2026-10-16 로티 쪽 실측). `terminal create`·`workspace add` 자체는 맨 앞 앱·활성 탭을 바꾸지 않아 포커스 없는 생성 옵션은 필요 없다. 그래서 `runRottie`(`src/rottie-cli.mjs`)는 주입 spawn이어도 첫 인자를 먼저 검사해 `ROTTIE_ARGV_REJECTED`(effect none)로 실행하지 않는다. - **어떤 종료에서 탭이 남을 수 있나**: 탭을 만드는 순간에 터미널 Ctrl-C처럼 프로세스 그룹 전체가 SIGINT를 받아 로티 CLI도 함께 죽은 경우, SIGKILL, 전원·강제 종료. 이때는 탭 주소(id)를 받지 못해 닫을 수 없고, 보기 탭 1개가 남을 수 있다. 주소를 모르면 정리하지 않는다(제목·키로 목록을 뒤져 지우면 남의 탭을 지울 수 있다). - **남은 탭 찾는 법**: 호출 폴더 `KADAN_HOME/advice/<호출ID>/tab.json`의 `key`(`kadan-senior-<호출ID>`, create 전에 먼저 쓴다) 또는 원장 `advice` 사건의 `tabKey`를 보고, 로티에서 `시니어 · <카드키>` 제목의 탭을 직접 닫는다. 신호 처리기가 돌 수 있고 저장이 성공했을 때만 `reason: interrupted`로 원장에 남는다. 저장 거절·SIGKILL·전원 종료 때는 증거 폴더 `tab.json`만 남는다. diff --git a/docs/watch-overview.md b/docs/watch-overview.md index eba3301d..fc1178d7 100644 --- a/docs/watch-overview.md +++ b/docs/watch-overview.md @@ -142,7 +142,7 @@ AI가 판단하는 동안에도 감시sh는 설정된 주기로 화면·세션· kadan watch-report --verdict <판정> --reason "판단 이유" ``` -호출ID는 쓰지 않는다(2026-10-17). AI가 긴 ID를 다시 치다 한 글자를 틀려 `report-missing`이 난 사고가 있었다. 명령이 ID를 직접 찾는다. 환경의 `KADAN_JUDGE_REQUEST`, 없으면 작업 폴더(`/watch-ai/`이고 그 호출 기록이 원장에 있을 때만)에서 읽고, 둘 다 없을 때만 넘긴 ID를 쓴다. 넘긴 ID가 원장에 있는 요청이면 그 ID를 쓰고(실제로 다른 요청을 지목한 것) 이 값과 다르면 `idMismatch`만 남긴다. 원장에 없는 값(오타)이면 이 값으로 복구하고 `idMismatch`를 남긴다. 환경과 점검 폴더가 둘 다 있는데 서로 다르면 오류로 끝내고 기록하지 않는다. 불일치 값은 ID 모양(영숫자·`-` 8~64자)일 때만 `givenRequestId`로 남고, 그 밖은 원문 없이 `givenRequestLength`·`givenRequestDigest`(sha256 앞 12자)만 남는다. 출력의 `requestIdSource`는 `arg|env|cwd`다. 아무것도 못 찾으면 오류이고 기록하지 않는다. +호출ID는 쓰지 않는다(2026-10-17). AI가 긴 ID를 다시 치다 한 글자를 틀려 `report-missing`이 난 사고가 있었다. 명령이 ID를 직접 찾는다. 환경의 `KADAN_JUDGE_REQUEST`, 없으면 작업 폴더(`/watch-ai/`이고 그 호출 기록이 원장에 있을 때만)에서 읽고, 둘 다 없을 때만 넘긴 ID를 쓴다. 넘긴 ID가 원장에 있는 요청이면 그 ID를 쓰고(실제로 다른 요청을 지목한 것) 이 값과 다르면 `idMismatch`만 남긴다. 원장에 없는 값(오타)이면 이 값으로 복구하고 `idMismatch`를 남긴다. 환경과 점검 폴더가 둘 다 있는데 서로 다르면 오류로 끝내고 기록하지 않는다. 다만 원장에 있는 요청 ID를 명시하면 그 ID가 우선이라 이 거절에 걸리지 않는다. 불일치 값은 ID 모양(영숫자·`-` 8~64자)일 때만 `givenRequestId`로 남고, 그 밖은 원문 없이 `givenRequestLength`·`givenRequestDigest`(sha256 앞 12자)만 남는다. 출력의 `requestIdSource`는 `arg|env|cwd`다. 아무것도 못 찾으면 오류이고 기록하지 않는다. | 판정 | 기록과 알림 | |---|---| diff --git a/src/adopt-process.mjs b/src/adopt-process.mjs index 5e5f5f78..43840b1f 100644 --- a/src/adopt-process.mjs +++ b/src/adopt-process.mjs @@ -1,6 +1,6 @@ import path from 'node:path'; import {spawnSync} from 'node:child_process'; -import {assertRottieArgv} from './floor-rottie.mjs'; +import {assertRottieArgv} from './rottie-cli.mjs'; export function processIdentity(pid, run = spawnSync) { if (!/^\d+$/.test(String(pid)) || Number(pid) <= 1) throw new Error('프로세스 PID 확인 실패'); diff --git a/src/cli-start-support.mjs b/src/cli-start-support.mjs index 23ad93ea..b2bae101 100644 --- a/src/cli-start-support.mjs +++ b/src/cli-start-support.mjs @@ -133,13 +133,13 @@ export function inspectHiddenStartPolicy({ // 임시 장치: 로티 정식 출시로 경로가 고정되면 이 점검은 걷어낸다 (2026-09-06 [kyle] 결정). export function inspectRottieStartPreflight({ env = process.env, - floorName, hidden = false, + forceWindow = false, existsFn = fs.existsSync, connectFn = rottieConnectivity, listBinsFn = listRunningRottieBins, } = {}) { - const needsWindow = floorName === "rottie" || env.KADAN_WINDOW === "rottie"; + const needsWindow = forceWindow || env.KADAN_WINDOW === "rottie"; if (!needsWindow || hidden) return { ok: true, skipped: true }; const bin = env.KADAN_ROTTIE_BIN; const candidates = listBinsFn(); @@ -280,7 +280,7 @@ export function closeStartedWindow(lastStart, { env = process.env, closeFn = clo if (!terminalId) return {}; // start의 자식 프로세스가 교정한 환경은 감독에게 돌아가지 않는다. 종료할 때도 다시 확인한다. // 현재 KADAN_WINDOW와 무관하게, 실제로 열었던 로티 탭 기록을 기준으로 한다. - const preflight = inspectRottieStartPreflight({env, floorName:"rottie", existsFn, connectFn, listBinsFn}); + const preflight = inspectRottieStartPreflight({env, forceWindow:true, existsFn, connectFn, listBinsFn}); const fields = {rottieTerminalId:terminalId, ...(preflight.switched ? {rottieBinSwitched:preflight.switched} : {})}; const failed = code => ({...fields, rottieWindowClosed:false, rottieWindowError:String(code)}); if (!preflight.ok) { diff --git a/src/cli.mjs b/src/cli.mjs index 21b94db0..0a2b4560 100755 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -38,7 +38,6 @@ import { tmuxConfText, hasSession, } from "./floor-tmux.mjs"; -import { buildRottieFloorCreateArgv } from "./floor-rottie.mjs"; import { createWallServer, renderWallHtml } from "./wall.mjs"; import { buildJudgeInput, @@ -147,7 +146,6 @@ export { SOCKET, buildOrcaCreateArgv, buildRottieCreateArgv, - buildRottieFloorCreateArgv, buildTmuxCreateArgv, capturePane, ensureTmuxConf, diff --git a/src/commands/inspect.mjs b/src/commands/inspect.mjs index 4232e4a3..4a9626a2 100644 --- a/src/commands/inspect.mjs +++ b/src/commands/inspect.mjs @@ -55,7 +55,7 @@ export function cmdAttach(argv) { if (!role) die("사용법: kadan attach <역할>"); const session = sessionName(role); if (!floor.alive(session)) die(`세션 없음: ${session}`); - if (["rottie", "herdr"].includes(floor.name)) { + if (floor.name === "herdr") { console.log(floor.attach(session)); return; } diff --git a/src/commands/restore.mjs b/src/commands/restore.mjs index 339ae994..ad4f944c 100644 --- a/src/commands/restore.mjs +++ b/src/commands/restore.mjs @@ -7,10 +7,10 @@ import { die, sessionName } from "../cli-basics.mjs"; import { requireWindowChoice, inspectRottieStartPreflight } from "../cli-start-support.mjs"; export function cmdRestore(argv, flags) { - if (floor.name !== "tmux") die("restore는 tmux 바닥에서만 쓴다 — 로티 바닥은 데몬과 함께 에이전트도 끝난다", 2); + if (floor.name !== "tmux") die("restore는 tmux 바닥에서만 쓴다", 2); const choice = requireWindowChoice(); if (choice.kind !== "rottie") die(`restore는 KADAN_WINDOW=rottie에서만 쓴다 (지금 ${choice.kind})`, 2); - const preflight = inspectRottieStartPreflight({ floorName: floor.name }); + const preflight = inspectRottieStartPreflight(); if (!preflight.ok) die(preflight.message, 2); if (preflight.switched) console.error(`로티 경로 자동 교정: ${preflight.switched.from ?? "(없음)"} → ${preflight.switched.to} (켜진 로티 1개, 연결 확인)`); const bin = process.env.KADAN_ROTTIE_BIN; diff --git a/src/commands/start.mjs b/src/commands/start.mjs index 08b14f55..b0838039 100644 --- a/src/commands/start.mjs +++ b/src/commands/start.mjs @@ -104,15 +104,8 @@ export function cmdStart(argv, flags) { console.log(`이미 살아 있음: ${session}`); const existingPid = floor.pid(session); evidence = - floor.name === "rottie" - ? { - rottieTerminalId: lastStartFor(session)?.rottieTerminalId, - rottiePid: existingPid, - rottieWorkspaceId: lastStartFor(session)?.rottieWorkspaceId, - rottieBuild: lastStartFor(session)?.rottieBuild, - } - : floor.name === "herdr" ? { ...lastStartFor(session), panePid: existingPid } - : { panePid: existingPid }; + floor.name === "herdr" ? { ...lastStartFor(session), panePid: existingPid } + : { panePid: existingPid }; } else { try { startedCmd = flags.cmd || undefined; @@ -127,8 +120,8 @@ export function cmdStart(argv, flags) { } } - const pid = evidence.panePid ?? evidence.rottiePid; - let method = floor.name === "rottie" ? "rottie" : floor.name === "herdr" ? "manual" : "hidden"; + const pid = evidence.panePid; + let method = floor.name === "herdr" ? "manual" : "hidden"; let rottieTerminalId; let orcaTerminalHandle; let reused; @@ -177,7 +170,7 @@ export function cmdStart(argv, flags) { die(`시작 기록 실패 — ${closed ? `방금 만든 ${session}을 닫았다` : `방금 만든 ${session}을 닫지 못했다(직접 확인)`}: ${error.message}`); } console.log( - `시작됨: ${session} (${floor.name === "rottie" ? "Rottie PID" : "pane PID"} ${pid ?? "?"}, 창: ${method}${ + `시작됨: ${session} (pane PID ${pid ?? "?"}, 창: ${method}${ method === "manual" ? ` — 직접: ${floor.attach(session)}` : "" })` ); diff --git a/src/commands/status.mjs b/src/commands/status.mjs index 3c45a6ac..ef6ab1a8 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -24,9 +24,9 @@ export function cmdStatus() { : `PID변경(${recorded}→${item.pid})`; const shown = startDisplay(lastStartFor(item.session)); const modelBit = ` ${shown.harness} · ${shown.model} · ${shown.family}`; - if (["rottie", "herdr"].includes(floor.name)) { + if (floor.name === "herdr") { console.log( - `${item.session} ${floor.name === "rottie" ? "Rottie" : "herdr"}=${item.terminalId} 상태=${item.state} ${pidState}${modelBit}` + `${item.session} herdr=${item.terminalId} 상태=${item.state} ${pidState}${modelBit}` ); } else { console.log( diff --git a/src/commands/watch.mjs b/src/commands/watch.mjs index 4e2d6483..e50cc1fe 100644 --- a/src/commands/watch.mjs +++ b/src/commands/watch.mjs @@ -85,7 +85,7 @@ export function sendWatchNudge(role,message,{selectedFloor=floor,readStart=lastS recordedPid:recordedPid(readStart(session)),notificationOnly:true},role,{humanIdleMs:WATCH_MAIL_HUMAN_IDLE_MS}); } -// 바닥이 보류 조회를 지원하지 않으면(rottie) 기존처럼 보류 없이 진행한다. +// 바닥이 보류 조회를 지원하지 않으면 기존처럼 보류 없이 진행한다. // 5번 연속 밀린 창은 사전 확인도 건너뛴다. 실제 입력 보호는 전송 쪽 guardFor가 같은 기준으로 뺀다. export function watchMailHold(role,{selectedFloor=floor,tracker=null}={}) { if (tracker?.forced(role)) return null; diff --git a/src/floor-rottie.mjs b/src/floor-rottie.mjs deleted file mode 100644 index bf0caf2a..00000000 --- a/src/floor-rottie.mjs +++ /dev/null @@ -1,409 +0,0 @@ -// Rottie CLI v1 바닥 — 공개 CLI 봉투만 사용하고 내부 파일 장부는 읽거나 쓰지 않는다. -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { readLedger } from "./ledger.mjs"; -import { assertNotLiveRottie } from "./live-floor-guard.mjs"; - -export class RottieFloorError extends Error { - constructor(message, { code = "ROTTIE_UNKNOWN", effect = "unknown", exitCode = 1 } = {}) { - super(`${code}: ${message}`); - this.name = "RottieFloorError"; - this.code = code; - this.effect = effect; - this.exitCode = exitCode; - } -} - -function rottieBin(env = process.env) { - const bin = env.KADAN_ROTTIE_BIN; - if (!bin || !path.isAbsolute(bin)) { - throw new RottieFloorError("KADAN_ROTTIE_BIN 절대경로가 필요하다", { - code: "KADAN_ROTTIE_BIN_REQUIRED", - effect: "none", - exitCode: 2, - }); - } - return bin; -} - -// 로티 실행 파일은 첫 인자가 아래 넷이 아니면 CLI가 아니라 앱 전체로 다시 떠서 약 3초 뒤 기존 창을 앞으로 가져온다 -// (2026-10-16 로티 쪽 실측 BG-final). 인자 없음·--version·오타가 그 꼴이라 kyle의 포커스를 뺏는다. 실행 전에 막는다. -export const ROTTIE_CLI_VERBS = ["status", "workspace", "terminal", "recovery"]; -export function assertRottieArgv(argv) { - if (Array.isArray(argv) && ROTTIE_CLI_VERBS.includes(argv[0])) return; - throw new RottieFloorError( - `로티 실행 파일의 첫 인자는 ${ROTTIE_CLI_VERBS.join("|")} 중 하나여야 한다(받은 값: ${JSON.stringify(argv?.[0] ?? null)}) — 앱이 다시 떠 포커스를 뺏을 수 있어 실행하지 않는다`, - { code: "ROTTIE_ARGV_REJECTED", effect: "none", exitCode: 2 } - ); -} - -// env는 기본 process.env다. 시니어 탭처럼 호출자가 정한 환경으로 로티를 부르는 쪽이 같은 길목을 쓰게 한다. -export function runRottie(argv, spawnFn = spawnSync, env = process.env) { - // 주입 spawnFn이어도 항상 적용한다(시험에서도 가드가 보이게). - assertRottieArgv(argv); - // 주입된 spawnFn은 실제 앱에 닿지 않는다. 진짜 spawnSync일 때만 운영 로티 접근을 막는다. - if (spawnFn === spawnSync) assertNotLiveRottie(env); - const result = spawnFn(rottieBin(env), argv, { - encoding: "utf8", - maxBuffer: 2 * 1024 * 1024, - }); - if (result.error) { - throw new RottieFloorError(result.error.message, { - code: result.error.code || "ROTTIE_SPAWN_FAILED", - effect: "unknown", - exitCode: 1, - }); - } - - let envelope; - try { - envelope = JSON.parse((result.stdout || "").trim()); - } catch { - throw new RottieFloorError( - `JSON 봉투를 읽지 못했다 (exit ${result.status ?? "?"})`, - { - code: "ROTTIE_JSON_PARSE", - effect: result.status === 0 ? "unknown" : "none", - exitCode: result.status || 1, - } - ); - } - if (result.status !== 0 || envelope.ok !== true) { - throw new RottieFloorError( - envelope.error?.message || (result.stderr || "").trim() || "Rottie CLI 실패", - { - code: envelope.error?.code || "ROTTIE_UNKNOWN", - effect: envelope.error?.effect || "unknown", - exitCode: result.status || 1, - } - ); - } - return envelope; -} - -function lastStartFor(session) { - const starts = readLedger().filter( - (entry) => - entry.kind === "start" && - entry.session === session && - entry.floor === "rottie" && - entry.rottieTerminalId - ); - return starts[starts.length - 1] || null; -} - -function terminalIdFor(session) { - const terminalId = lastStartFor(session)?.rottieTerminalId; - if (!terminalId) { - throw new RottieFloorError(`원장에 Rottie 주소가 없다: ${session}`, { - code: "KADAN_ROTTIE_ADDRESS_MISSING", - effect: "none", - exitCode: 1, - }); - } - return terminalId; -} - -function showTerminal(session) { - const terminalId = terminalIdFor(session); - return runRottie(["terminal", "show", "--terminal", terminalId, "--json"]).result - .terminal; -} - -export function buildRottieSendArgv({ - terminalId, - bodyFile, - idempotencyKey, - noEnter = false, -}) { - return [ - "terminal", - "send", - "--terminal", - terminalId, - "--body-file", - bodyFile, - ...(noEnter ? ["--no-enter"] : []), - "--idempotency-key", - idempotencyKey, - "--json", - ]; -} - -export function parseRottieCreateEvidence(envelope) { - const terminal = envelope.result?.terminal; - const build = envelope.runtime?.build; - if ( - !terminal?.id || - terminal.state !== "running" || - typeof build !== "string" || - build.length === 0 - ) { - throw new RottieFloorError( - "create 응답에 실행 중인 terminal.id 또는 runtime.build가 없다", - { - code: "ROTTIE_CREATE_RESULT_INVALID", - effect: "unknown", - exitCode: 1, - } - ); - } - return { - rottieTerminalId: terminal.id, - rottiePid: String(terminal.pid), - rottieWorkspaceId: terminal.workspaceId, - rottieBuild: build, - }; -} - -export function buildRottieFloorCreateArgv({ - session, - role, - cwd, - cmd, - idempotencyKey, -}) { - const command = cmd || process.env.SHELL || "/bin/zsh"; - const environmentRole = session.startsWith("kadan-") - ? session.slice("kadan-".length) - : role; - return [ - "terminal", - "create", - "--workspace", - cwd, - "--title", - role, - "--command", - `env KADAN_ROLE=${environmentRole} ${command}`, - "--idempotency-key", - idempotencyKey, - "--json", - ]; -} - -function create({ session, role, cwd, cmd }) { - const idempotencyKey = `kadan-${session}-${Date.now()}`; - const argv = buildRottieFloorCreateArgv({ - session, - role, - cwd, - cmd, - idempotencyKey, - }); - - let envelope; - try { - envelope = runRottie(argv); - } catch (error) { - if ( - error instanceof RottieFloorError && - error.code === "ROTTIE_WORKSPACE_NOT_OPEN" && - error.effect === "none" - ) { - runRottie(["workspace", "add", "--path", cwd, "--json"]); - envelope = runRottie(argv); - } else { - throw error; - } - } - return parseRottieCreateEvidence(envelope); -} - -function alive(session) { - if (!lastStartFor(session)) return false; - return showTerminal(session).state === "running"; -} - -function pid(session) { - if (!lastStartFor(session)) return null; - const terminal = showTerminal(session); - return terminal.pid == null ? null : String(terminal.pid); -} - -function read(session, options = {}) { - const terminalId = terminalIdFor(session); - const argv = [ - "terminal", - "output", - "--terminal", - terminalId, - ...(options.since ? ["--since", options.since] : []), - "--max-bytes", - "1048576", - "--json", - ]; - const result = runRottie(argv).result; - const chunks = Array.isArray(result?.chunks) ? result.chunks : []; - return { - text: chunks - .map((chunk) => Buffer.from(chunk.bytesBase64 || "", "base64").toString("utf8")) - .join(""), - cursor: result?.nextCursor ?? null, - gap: result?.gap ?? null, - incremental: Boolean(options.since), - terminal: result?.terminal, - retention: result?.retention, - }; -} - -export function sendRottieText({ - terminalId, - session, - text, - baselineText, - baselineGap = null, - spawnFn = spawnSync, - now = Date.now, - tempRoot = os.tmpdir(), -}) { - const tempDir = fs.mkdtempSync(path.join(tempRoot, "kadan-send-")); - const bodyFile = path.join(tempDir, "body.txt"); - const enterFile = path.join(tempDir, "enter.txt"); - fs.writeFileSync(bodyFile, text, { encoding: "utf8", mode: 0o600 }); - fs.writeFileSync(enterFile, "\r", { encoding: "utf8", mode: 0o600 }); - try { - const idempotencyKey = `kadan-send-${session}-${now()}`; - const result = runRottie( - buildRottieSendArgv({ - terminalId, - bodyFile, - idempotencyKey, - noEnter: true, - }), - spawnFn - ).result; - const delivery = result?.delivery; - if (!delivery?.bodySha256 || !Number.isInteger(delivery.sequence)) { - throw new RottieFloorError("send 응답에 영수증 필드가 없다", { - code: "ROTTIE_SEND_RESULT_INVALID", - effect: "unknown", - exitCode: 1, - }); - } - if (delivery.outputCursor) { - runRottie([ - "terminal", - "wait", - "--terminal", - terminalId, - "--until", - "output", - "--since", - delivery.outputCursor, - "--timeout-seconds", - "2", - "--json", - ], spawnFn); - } - const enterResult = runRottie( - buildRottieSendArgv({ - terminalId, - bodyFile: enterFile, - idempotencyKey: `${idempotencyKey}-enter`, - noEnter: true, - }), - spawnFn - ).result; - return { - terminalId, - sequence: delivery.sequence, - enterSequence: enterResult?.delivery?.sequence, - bodySha256: delivery.bodySha256, - outputCursor: - enterResult?.delivery?.outputCursor ?? delivery.outputCursor, - deliveryId: delivery.id, - baselineText, - gap: baselineGap, - }; - } finally { - fs.rmSync(tempDir, { recursive: true }); - } -} - -function send(session, text) { - const terminalId = terminalIdFor(session); - const baseline = read(session); - return sendRottieText({ - terminalId, - session, - text, - baselineText: baseline.text, - baselineGap: baseline.gap, - }); -} - -function stop(session) { - const terminalId = terminalIdFor(session); - runRottie(["terminal", "close", "--terminal", terminalId, "--json"]); -} - -function list() { - const latestBySession = new Map(); - for (const entry of readLedger()) { - if (entry.kind === "stop" || entry.kind === "start") latestBySession.delete(entry.session); - if ( - entry.kind === "start" && - entry.floor === "rottie" && - entry.rottieTerminalId - ) { - latestBySession.set(entry.session, entry); - } - } - return [...latestBySession.values()].flatMap((entry) => { - let terminal; - try { - terminal = runRottie(["terminal", "show", "--terminal", entry.rottieTerminalId, "--json"]).result.terminal; - } catch (error) { - if (error instanceof RottieFloorError && error.code === "ROTTIE_TERMINAL_NOT_FOUND") return []; - throw error; - } - return [{ - session: entry.session, - alive: terminal.state === "running", - pid: terminal.pid == null ? null : String(terminal.pid), - state: terminal.state, - terminalId: terminal.id, - }]; - }); -} - -function waitForChange(session, ms, cursor) { - if (!cursor) return null; - const terminalId = terminalIdFor(session); - const result = runRottie([ - "terminal", - "wait", - "--terminal", - terminalId, - "--until", - "output", - "--since", - cursor, - "--timeout-seconds", - String(Math.max(1, Math.min(600, Math.ceil(ms / 1000)))), - "--json", - ]).result; - return { - reached: result?.reached === true, - reason: result?.reason, - cursor: result?.cursor, - }; -} - -export const rottieFloor = { - name: "rottie", - create, - alive, - pid, - send, - read, - stop, - list, - waitForChange, - attach(session) { - return `Rottie 패널 ${terminalIdFor(session)}에서 보세요`; - }, -}; diff --git a/src/floor-tmux.mjs b/src/floor-tmux.mjs index f462d5c7..9c96ab1a 100644 --- a/src/floor-tmux.mjs +++ b/src/floor-tmux.mjs @@ -5,7 +5,7 @@ import path from "node:path"; import { matchesAiProcess } from "./ai-identity.mjs"; import { ledgerHome } from "./ledger.mjs"; import { assertNotLiveFloor } from "./live-floor-guard.mjs"; -import { assertRottieArgv } from "./floor-rottie.mjs"; +import { assertRottieArgv } from "./rottie-cli.mjs"; import { HUMAN_IDLE_MS } from "./notification-guard.mjs"; export const SOCKET = process.env.KADAN_SOCKET || process.env.KADAN_LITE_SOCKET || "kadan"; diff --git a/src/floor.mjs b/src/floor.mjs index a8fcd8ba..77ab15e5 100644 --- a/src/floor.mjs +++ b/src/floor.mjs @@ -1,13 +1,11 @@ import { tmuxFloor } from "./floor-tmux.mjs"; -import { rottieFloor } from "./floor-rottie.mjs"; import { herdrFloor } from "./floor-herdr.mjs"; export function selectFloorName(value = "tmux") { if (value === "tmux") return tmuxFloor; - if (value === "rottie") return rottieFloor; if (value === "herdr") return herdrFloor; const error = new Error( - `KADAN_FLOOR_INVALID: KADAN_FLOOR=${value} (tmux|rottie|herdr만 허용)` + `KADAN_FLOOR_INVALID: KADAN_FLOOR=${value} (tmux|herdr만 허용)` ); error.code = "KADAN_FLOOR_INVALID"; error.exitCode = 2; diff --git a/src/rottie-cli.mjs b/src/rottie-cli.mjs new file mode 100644 index 00000000..4aa641cf --- /dev/null +++ b/src/rottie-cli.mjs @@ -0,0 +1,106 @@ +// Rottie 공개 CLI 호출 길목 — 창(KADAN_WINDOW=rottie)·시니어 탭·adopt가 함께 쓴다. 바닥은 아니다. +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { assertNotLiveRottie } from "./live-floor-guard.mjs"; + +export class RottieFloorError extends Error { + constructor(message, { code = "ROTTIE_UNKNOWN", effect = "unknown", exitCode = 1 } = {}) { + super(`${code}: ${message}`); + this.name = "RottieFloorError"; + this.code = code; + this.effect = effect; + this.exitCode = exitCode; + } +} + +function rottieBin(env = process.env) { + const bin = env.KADAN_ROTTIE_BIN; + if (!bin || !path.isAbsolute(bin)) { + throw new RottieFloorError("KADAN_ROTTIE_BIN 절대경로가 필요하다", { + code: "KADAN_ROTTIE_BIN_REQUIRED", + effect: "none", + exitCode: 2, + }); + } + return bin; +} + +// 로티 실행 파일은 첫 인자가 아래 넷이 아니면 CLI가 아니라 앱 전체로 다시 떠서 약 3초 뒤 기존 창을 앞으로 가져온다 +// (2026-10-16 로티 쪽 실측 BG-final). 인자 없음·--version·오타가 그 꼴이라 kyle의 포커스를 뺏는다. 실행 전에 막는다. +export const ROTTIE_CLI_VERBS = ["status", "workspace", "terminal", "recovery"]; +export function assertRottieArgv(argv) { + if (Array.isArray(argv) && ROTTIE_CLI_VERBS.includes(argv[0])) return; + throw new RottieFloorError( + `로티 실행 파일의 첫 인자는 ${ROTTIE_CLI_VERBS.join("|")} 중 하나여야 한다(받은 값: ${JSON.stringify(argv?.[0] ?? null)}) — 앱이 다시 떠 포커스를 뺏을 수 있어 실행하지 않는다`, + { code: "ROTTIE_ARGV_REJECTED", effect: "none", exitCode: 2 } + ); +} + +// env는 기본 process.env다. 시니어 탭처럼 호출자가 정한 환경으로 로티를 부르는 쪽이 같은 길목을 쓰게 한다. +export function runRottie(argv, spawnFn = spawnSync, env = process.env) { + // 주입 spawnFn이어도 항상 적용한다(시험에서도 가드가 보이게). + assertRottieArgv(argv); + // 주입된 spawnFn은 실제 앱에 닿지 않는다. 진짜 spawnSync일 때만 운영 로티 접근을 막는다. + if (spawnFn === spawnSync) assertNotLiveRottie(env); + const result = spawnFn(rottieBin(env), argv, { + encoding: "utf8", + maxBuffer: 2 * 1024 * 1024, + }); + if (result.error) { + throw new RottieFloorError(result.error.message, { + code: result.error.code || "ROTTIE_SPAWN_FAILED", + effect: "unknown", + exitCode: 1, + }); + } + + let envelope; + try { + envelope = JSON.parse((result.stdout || "").trim()); + } catch { + throw new RottieFloorError( + `JSON 봉투를 읽지 못했다 (exit ${result.status ?? "?"})`, + { + code: "ROTTIE_JSON_PARSE", + effect: result.status === 0 ? "unknown" : "none", + exitCode: result.status || 1, + } + ); + } + if (result.status !== 0 || envelope.ok !== true) { + throw new RottieFloorError( + envelope.error?.message || (result.stderr || "").trim() || "Rottie CLI 실패", + { + code: envelope.error?.code || "ROTTIE_UNKNOWN", + effect: envelope.error?.effect || "unknown", + exitCode: result.status || 1, + } + ); + } + return envelope; +} +export function parseRottieCreateEvidence(envelope) { + const terminal = envelope.result?.terminal; + const build = envelope.runtime?.build; + if ( + !terminal?.id || + terminal.state !== "running" || + typeof build !== "string" || + build.length === 0 + ) { + throw new RottieFloorError( + "create 응답에 실행 중인 terminal.id 또는 runtime.build가 없다", + { + code: "ROTTIE_CREATE_RESULT_INVALID", + effect: "unknown", + exitCode: 1, + } + ); + } + return { + rottieTerminalId: terminal.id, + rottiePid: String(terminal.pid), + rottieWorkspaceId: terminal.workspaceId, + rottieBuild: build, + }; +} diff --git a/src/senior-tab.mjs b/src/senior-tab.mjs index fb5a4f30..11e15c9b 100644 --- a/src/senior-tab.mjs +++ b/src/senior-tab.mjs @@ -1,12 +1,12 @@ // 시니어 따라 보기 탭 — 호출이 도는 동안만 로티에 읽기 전용 탭을 띄워 진행 출력을 보여 준다(2026-10-16 [kyle]). // 탭은 꾸밈이다: 어떤 실패도 던지지 않고 건너뜀 사유로 돌려준다. 역할이 아니므로 KADAN_ROLE·kadan-* 이름·start 원장 사건을 쓰지 않는다. -// 로티 호출은 floor-rottie의 runRottie 길목(운영 로티 보호 포함)을 그대로 쓴다. +// 로티 호출은 rottie-cli의 runRottie 길목(운영 로티 보호 포함)을 그대로 쓴다. import fs from 'node:fs'; import path from 'node:path'; import {spawnSync} from 'node:child_process'; import {effectiveWindow} from './window-setting.mjs'; import {assertNotLiveRottie} from './live-floor-guard.mjs'; -import {parseRottieCreateEvidence, runRottie} from './floor-rottie.mjs'; +import {parseRottieCreateEvidence, runRottie} from './rottie-cli.mjs'; // 'on'이면 켜고 'off'면 끈다. 없으면 SENIOR_TAB_DEFAULT_ON을 따른다. // 로티 terminal create에는 포커스를 끄는 옵션이 없다. 2026-10-16 실측(docs/senior.md)에서 로티가 맨 앞으로 오지 않아 기본은 켬이다. diff --git a/tests/adopt.test.mjs b/tests/adopt.test.mjs index 5daa8d5e..1a68b4f1 100644 --- a/tests/adopt.test.mjs +++ b/tests/adopt.test.mjs @@ -115,11 +115,11 @@ test('adopt: 화면이 끝내 안 뜨면 첫 안내를 보내지 않고 알린 assert.ok(calls.log.some((l) => l.includes('첫 안내를 보내지 않았습니다'))); }); -test('HW-4: KADAN_FLOOR가 없으면 바닥은 tmux이고 herdr·rottie는 명시했을 때만 고른다(자동 폴백 없음)', async () => { +test('HW-4: KADAN_FLOOR가 없으면 바닥은 tmux이고 herdr는 명시했을 때만 고르고 rottie는 고를 수 없다(자동 폴백 없음)', async () => { const { selectFloorName } = await import('../src/floor.mjs'); assert.equal(selectFloorName().name, 'tmux'); assert.equal(selectFloorName('herdr').name, 'herdr'); - assert.equal(selectFloorName('rottie').name, 'rottie'); + assert.throws(() => selectFloorName('rottie'), /KADAN_FLOOR_INVALID/); assert.throws(() => selectFloorName('herdr2'), /KADAN_FLOOR_INVALID/); }); diff --git a/tests/rottie-floor.test.mjs b/tests/floor-common.test.mjs similarity index 63% rename from tests/rottie-floor.test.mjs rename to tests/floor-common.test.mjs index bcd665af..6e012967 100644 --- a/tests/rottie-floor.test.mjs +++ b/tests/floor-common.test.mjs @@ -1,4 +1,4 @@ -// 로티 바닥 회귀 — 2026-08-30 card-18 사고 조건을 가짜 바닥으로 잠근다. +// 바닥 공용 회귀 — 2026-08-30 card-18 사고 조건을 가짜 바닥으로 잠근다(로티 바닥은 없앴고, 공용 대기·전송 가드와 로티 생성 응답 해석만 남는다). import './helpers/isolated-home.mjs'; import { test } from "node:test"; import assert from "node:assert/strict"; @@ -16,11 +16,7 @@ import { waitSnapshotPath, } from "../src/cli.mjs"; import { selectFloorName } from "../src/floor.mjs"; -import { - buildRottieSendArgv, - parseRottieCreateEvidence, - sendRottieText, -} from "../src/floor-rottie.mjs"; +import { parseRottieCreateEvidence } from "../src/rottie-cli.mjs"; test("입력 에코에 완성 마커가 있고 새 출력이 없으면 ok가 아니라 사람 확인이다 — 2026-08-30 에코 오판", () => { let now = 0; @@ -113,35 +109,16 @@ test("Rottie 출력 gap은 마커 유실 가능성이라 exit 2로 닫는다 — }); }); -test("Rottie send argv는 셸 문자열 없이 body-file 절대경로를 한 요소로 쓴다", () => { - const argv = buildRottieSendArgv({ - terminalId: "term_123", - bodyFile: "/tmp/kadan send/본문.txt", - idempotencyKey: "kadan-send-kadan-worker-1", - }); - - assert.deepEqual(argv, [ - "terminal", - "send", - "--terminal", - "term_123", - "--body-file", - "/tmp/kadan send/본문.txt", - "--idempotency-key", - "kadan-send-kadan-worker-1", - "--json", - ]); - assert.equal(argv.some((arg) => arg === "--body"), false); -}); - -test("KADAN_FLOOR 오타는 자동 감지나 폴백 없이 typed exit 2 오류다", () => { - assert.throws( - () => selectFloorName("rotite"), - (error) => - error.code === "KADAN_FLOOR_INVALID" && - error.exitCode === 2 && - /tmux\|rottie/.test(error.message) - ); +test("KADAN_FLOOR 오타와 rottie는 자동 감지나 폴백 없이 typed exit 2 오류다 — 로티는 창으로만 쓴다", () => { + for (const value of ["rotite", "rottie"]) { + assert.throws( + () => selectFloorName(value), + (error) => + error.code === "KADAN_FLOOR_INVALID" && + error.exitCode === 2 && + /tmux\|herdr만 허용/.test(error.message) + ); + } }); test("Codex가 마커 뒤 커서를 입력창으로 옮겨도 줄 경계를 보존한다 — 2026-08-30 Rottie 원시 스트림", () => { @@ -152,98 +129,6 @@ test("Codex가 마커 뒤 커서를 입력창으로 옮겨도 줄 경계를 보 ]); }); -test("Rottie는 붙여넣기 직후 Enter를 삼킨다 — 2026-08-30 card-18 실측", () => { - const previousBin = process.env.KADAN_ROTTIE_BIN; - process.env.KADAN_ROTTIE_BIN = "/fake/rottie"; - const calls = []; - const spawnFn = (_bin, argv) => { - calls.push(argv); - if (argv[0] === "terminal" && argv[1] === "wait") { - return { - status: 0, - stdout: JSON.stringify({ - ok: true, - result: { reached: true, reason: "output", cursor: "c:11" }, - }), - }; - } - const sequence = calls.filter( - (call) => call[0] === "terminal" && call[1] === "send" - ).length; - return { - status: 0, - stdout: JSON.stringify({ - ok: true, - result: { - delivery: { - id: `delivery-${sequence}`, - sequence, - bodySha256: `sha-${sequence}`, - outputCursor: `c:${sequence * 10}`, - }, - }, - }), - }; - }; - - try { - sendRottieText({ - terminalId: "term-order", - session: "kadan-order", - text: "본문", - baselineText: "", - spawnFn, - now: () => 123, - }); - } finally { - if (previousBin === undefined) delete process.env.KADAN_ROTTIE_BIN; - else process.env.KADAN_ROTTIE_BIN = previousBin; - } - - assert.equal(calls.length, 3); - assert.deepEqual(calls[0].slice(0, 5), [ - "terminal", - "send", - "--terminal", - "term-order", - "--body-file", - ]); - assert.ok(calls[0][5].endsWith("/body.txt")); - assert.deepEqual(calls[0].slice(6), [ - "--no-enter", - "--idempotency-key", - "kadan-send-kadan-order-123", - "--json", - ]); - assert.deepEqual(calls[1], [ - "terminal", - "wait", - "--terminal", - "term-order", - "--until", - "output", - "--since", - "c:10", - "--timeout-seconds", - "2", - "--json", - ]); - assert.deepEqual(calls[2].slice(0, 5), [ - "terminal", - "send", - "--terminal", - "term-order", - "--body-file", - ]); - assert.ok(calls[2][5].endsWith("/enter.txt")); - assert.deepEqual(calls[2].slice(6), [ - "--no-enter", - "--idempotency-key", - "kadan-send-kadan-order-123-enter", - "--json", - ]); -}); - test("Rottie create 가짜 응답의 runtime build를 start 원장에 남긴다 — 2026-08-30 build 불일치", () => { const evidence = parseRottieCreateEvidence({ ok: true, @@ -258,7 +143,7 @@ test("Rottie create 가짜 응답의 runtime build를 start 원장에 남긴다 runtime: { build: "build-from-response" }, }); const entry = buildStartLedgerEntry({ - floorName: "rottie", + floorName: "tmux", role: "worker", session: "kadan-worker", evidence, diff --git a/tests/floor-rottie-removed.test.mjs b/tests/floor-rottie-removed.test.mjs new file mode 100644 index 00000000..3f529ffe --- /dev/null +++ b/tests/floor-rottie-removed.test.mjs @@ -0,0 +1,40 @@ +// 로티는 창으로만 쓴다 — 바닥 값으로는 거절하고, 바닥 시절(9/15 이전)의 옛 원장 줄은 여전히 읽힌다. +import './helpers/isolated-home.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {spawnSync} from 'node:child_process'; +import {appendLedger} from '../src/ledger.mjs'; +import {renderLogEntry} from '../src/cli.mjs'; +import {selectFloorName} from '../src/floor.mjs'; + +function cli(home, args, extra = {}) { + const env = {PATH: process.env.PATH, HOME: process.env.HOME, KADAN_HOME: home, + KADAN_SOCKET: `rr-${process.pid}-${Math.random().toString(36).slice(2, 8)}`, KADAN_WINDOW: 'none', KADAN_NOTIFY: 'off', ...extra}; + return spawnSync(process.execPath, ['src/cli.mjs', ...args], {env, encoding: 'utf8', timeout: 20000}); +} + +test('KADAN_FLOOR=rottie는 KADAN_FLOOR_INVALID(tmux|herdr만 허용)로 거절한다', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kadan-rottie-gone-')); + const run = cli(home, ['status'], {KADAN_FLOOR: 'rottie'}); + assert.equal(run.status, 2); + assert.match(run.stderr, /KADAN_FLOOR_INVALID.*tmux\|herdr만 허용/); + assert.throws(() => selectFloorName('rottie'), {code: 'KADAN_FLOOR_INVALID'}); + assert.equal(selectFloorName('tmux').name, 'tmux'); + assert.equal(selectFloorName('herdr').name, 'herdr'); +}); + +test('옛 로티 바닥 시작 줄(floor:"rottie", rottiePid·rottieBuild)이 있어도 log·status가 오류 없이 돈다', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kadan-rottie-old-ledger-')); + const old = {kind: 'start', role: 'old-worker', session: 'kadan-old-worker', floor: 'rottie', window: 'rottie', + rottieTerminalId: 'term-old', rottiePid: '4321', rottieWorkspaceId: 'ws-old', rottieBuild: 'build-old'}; + appendLedger(old, home); + assert.match(renderLogEntry({...old, t: '2026-09-01T00:00:00.000Z', by: 'x'}), /PID=4321 창=rottie/); + const log = cli(home, ['log']); + assert.equal(log.status, 0, log.stderr); + assert.match(log.stdout, /PID=4321/); + const status = cli(home, ['status'], {KADAN_FLOOR: 'tmux'}); + assert.equal(status.status, 0, status.stderr); +}); diff --git a/tests/herdr-floor.test.mjs b/tests/herdr-floor.test.mjs index 904707d4..919d14fe 100644 --- a/tests/herdr-floor.test.mjs +++ b/tests/herdr-floor.test.mjs @@ -38,9 +38,9 @@ function fixture() { return { floor, state, env, start, pane }; } -test("herdr는 명시 선택이며 기본 tmux와 기존 rottie를 바꾸지 않는다", () => { +test("herdr는 명시 선택이며 기본 tmux를 바꾸지 않고 rottie는 바닥으로 고를 수 없다", () => { assert.equal(selectFloorName().name, "tmux"); - assert.equal(selectFloorName("rottie").name, "rottie"); + assert.throws(() => selectFloorName("rottie"), /KADAN_FLOOR_INVALID/); assert.equal(selectFloorName("herdr").name, "herdr"); assert.throws(() => makeHerdrFloor({ env: {}, entries: () => [] }).list(), /KADAN_HERDR_SCOPE/); }); diff --git a/tests/hidden-start-policy.test.mjs b/tests/hidden-start-policy.test.mjs index 771c734b..7fe41390 100644 --- a/tests/hidden-start-policy.test.mjs +++ b/tests/hidden-start-policy.test.mjs @@ -34,7 +34,7 @@ test("KADAN_WINDOW=rottie + 역할 프로필 + --hidden은 거부된다", () => assert.match(denied.message, /worker/); }); -test("프로필 없음·none·비hidden·재사용·rottie 바닥은 허용한다", () => { +test("프로필 없음·none·비hidden·재사용은 허용한다", () => { const base = { env: { KADAN_WINDOW: "rottie" }, floorName: "tmux", @@ -46,7 +46,6 @@ test("프로필 없음·none·비hidden·재사용·rottie 바닥은 허용한 assert.equal(cli.inspectHiddenStartPolicy({ ...base, env: { KADAN_WINDOW: "none" } }).ok, true); assert.equal(cli.inspectHiddenStartPolicy({ ...base, hidden: false }).ok, true); assert.equal(cli.inspectHiddenStartPolicy({ ...base, reusing: true }).ok, true); - assert.equal(cli.inspectHiddenStartPolicy({ ...base, floorName: "rottie" }).ok, true); }); test("KADAN_WINDOW=rottie에서 프로필 세션의 --hidden은 세션을 만들기 전에 죽는다", (t) => { diff --git a/tests/live-floor-guard.test.mjs b/tests/live-floor-guard.test.mjs index 58a0fe18..05a379a2 100644 --- a/tests/live-floor-guard.test.mjs +++ b/tests/live-floor-guard.test.mjs @@ -113,13 +113,13 @@ test('로티 길목: 기본 아닌 홈은 거절, 시험 전용 허용·전체 assert.match(liveFloorReason({KADAN_HOME: other, KADAN_ALLOW_FAKE_ROTTIE: '1'}, defaultHome) ?? '', /KADAN_SOCKET/); }); -test('로티 바닥 실행(runRottie)은 기본 아닌 홈이면 로티 실행 파일을 띄우지 않는다', () => { +test('로티 실행(runRottie)은 기본 아닌 홈이면 로티 실행 파일을 띄우지 않는다', () => { const marker = path.join(tmp, 'rottie-ran.txt'); const fake = path.join(tmp, 'fake-rottie'); fs.writeFileSync(fake, `#!/bin/sh\necho ran >> ${JSON.stringify(marker)}\necho '{"ok":true,"result":{"terminals":[]}}'\n`, {mode: 0o755}); - const base = {PATH: process.env.PATH, HOME: process.env.HOME, KADAN_HOME: other, KADAN_SOCKET: 'iso-r', KADAN_FLOOR: 'rottie', KADAN_ROTTIE_BIN: fake, KADAN_NOTIFY: 'off'}; - const script = `import {rottieFloor} from ${JSON.stringify(new URL('../src/floor-rottie.mjs', import.meta.url).href)}; - try { rottieFloor.create({session: 'kadan-x', role: 'x', cwd: '/tmp', cmd: 'cat'}); console.log('RAN'); } catch (e) { console.log('ERR ' + e.message); }`; + const base = {PATH: process.env.PATH, HOME: process.env.HOME, KADAN_HOME: other, KADAN_SOCKET: 'iso-r', KADAN_ROTTIE_BIN: fake, KADAN_NOTIFY: 'off'}; + const script = `import {runRottie} from ${JSON.stringify(new URL('../src/rottie-cli.mjs', import.meta.url).href)}; + try { runRottie(['status', '--json']); console.log('RAN'); } catch (e) { console.log('ERR ' + e.message); }`; const run = extra => spawnSync(process.execPath, ['--input-type=module', '-e', script], {env: {...base, ...extra}, encoding: 'utf8'}); const denied = run({}); assert.match(denied.stdout + denied.stderr, /KADAN_LIVE_FLOOR_DENIED/, denied.stdout + denied.stderr); diff --git a/tests/rottie-list.test.mjs b/tests/rottie-list.test.mjs deleted file mode 100644 index 4479660a..00000000 --- a/tests/rottie-list.test.mjs +++ /dev/null @@ -1,73 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import {appendLedger} from '../src/ledger.mjs'; -import {rottieFloor} from '../src/floor-rottie.mjs'; - -function fixture(t) { - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kadan-rottie-list-')); - const bin = path.join(home, 'rottie.mjs'); - const calls = path.join(home, 'calls.jsonl'); - const config = path.join(home, 'responses.json'); - fs.writeFileSync(config, '{}'); - fs.writeFileSync(bin, `#!${process.execPath} -import fs from 'node:fs'; -const args = process.argv.slice(2), id = args[args.indexOf('--terminal') + 1]; -fs.appendFileSync(${JSON.stringify(calls)}, JSON.stringify(id) + '\\n'); -const responses = JSON.parse(fs.readFileSync(${JSON.stringify(config)}, 'utf8')); -const code = responses[id]; -if (code === 'invalid-json') console.log('broken'); -else if (code) { - console.log(JSON.stringify({ok:false,error:{code,message:'fixture error',effect:'none'}})); - process.exitCode = 1; -} else console.log(JSON.stringify({ok:true,result:{terminal:{id,pid:123,state:'running'}}})); -`, {mode: 0o700}); - const before = {KADAN_HOME: process.env.KADAN_HOME, KADAN_ROTTIE_BIN: process.env.KADAN_ROTTIE_BIN, - KADAN_ALLOW_FAKE_ROTTIE: process.env.KADAN_ALLOW_FAKE_ROTTIE}; - process.env.KADAN_HOME = home; - process.env.KADAN_ROTTIE_BIN = bin; - process.env.KADAN_ALLOW_FAKE_ROTTIE = '1'; // 가짜 로티 실행 파일: 로티 길목 검사만 푼다(tmux 검사는 그대로) - t.after(() => { - for (const [key, value] of Object.entries(before)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - }); - const start = (role, id = role, floor = 'rottie') => appendLedger({ - kind: 'start', role, session: `kadan-${role}`, floor, rottieTerminalId: id, rottiePid: '123', - }, home); - return {home, config, start, calls: () => fs.readFileSync(calls, 'utf8').trim().split('\n').map(JSON.parse)}; -} - -test('종료된 세대·바닥이 바뀐 세대·사라진 개별 터미널을 제외하고 살아 있는 목록을 반환한다', t => { - const f = fixture(t); - f.start('stopped'); - appendLedger({kind: 'stop', role: 'stopped', session: 'kadan-stopped'}, f.home); - f.start('changed'); - f.start('changed', 'new-tmux', 'tmux'); - f.start('missing'); - f.start('restarted', 'old'); - appendLedger({kind: 'stop', role: 'restarted', session: 'kadan-restarted'}, f.home); - f.start('restarted', 'new'); - f.start('live'); - fs.writeFileSync(f.config, JSON.stringify({ - stopped: 'ROTTIE_TERMINAL_NOT_FOUND', changed: 'ROTTIE_TERMINAL_NOT_FOUND', - missing: 'ROTTIE_TERMINAL_NOT_FOUND', old: 'ROTTIE_TERMINAL_NOT_FOUND', - })); - assert.deepEqual(rottieFloor.list().map(row => [row.session, row.terminalId, row.alive]), [ - ['kadan-restarted', 'new', true], ['kadan-live', 'live', true], - ]); - assert.deepEqual(f.calls(), ['missing', 'new', 'live']); -}); - -for (const code of ['ROTTIE_APP_NOT_RUNNING', 'ROTTIE_INTERNAL', 'invalid-json']) { - test(`목록 조회의 ${code} 오류는 빈 목록으로 숨기지 않는다`, t => { - const f = fixture(t); - f.start('live'); - fs.writeFileSync(f.config, JSON.stringify({live: code})); - assert.throws(() => rottieFloor.list(), error => - error.code === (code === 'invalid-json' ? 'ROTTIE_JSON_PARSE' : code)); - }); -} diff --git a/tests/senior-tab.test.mjs b/tests/senior-tab.test.mjs index 50f5887b..b272bf26 100644 --- a/tests/senior-tab.test.mjs +++ b/tests/senior-tab.test.mjs @@ -6,7 +6,7 @@ import os from 'node:os'; import path from 'node:path'; import {spawn, spawnSync} from 'node:child_process'; import {seniorCommand} from '../src/senior.mjs'; -import {RottieFloorError, assertRottieArgv, rottieFloor, runRottie} from '../src/floor-rottie.mjs'; +import {RottieFloorError, assertRottieArgv, runRottie} from '../src/rottie-cli.mjs'; import {closeSeniorTab} from '../src/senior-tab.mjs'; import {initSettings, setRole} from '../src/runner-settings.mjs'; import {CardStore} from '../src/card-store.mjs'; @@ -91,11 +91,6 @@ test('성공: 열고 닫은 뒤 advice 사건에 tab·rottieTerminalId가 남고 const [entry] = f.advices(); assert.equal(entry.tab, 'closed'); assert.equal(entry.rottieTerminalId, 'term-1'); assert.equal(entry.reason, 'ok'); assert.deepEqual(readLedger(f.home).filter(e => ['start', 'stop'].includes(e.kind)), []); - // 바닥 list()는 start 사건이 있는 세션만 본다 — 시니어 탭은 start가 없으므로 로티에 묻지도 않는다. - const before = {home: process.env.KADAN_HOME, bin: process.env.KADAN_ROTTIE_BIN}; - process.env.KADAN_HOME = f.home; process.env.KADAN_ROTTIE_BIN = '/nonexistent/rottie'; - try { assert.deepEqual(rottieFloor.list(), []); } - finally { process.env.KADAN_HOME = before.home; if (before.bin === undefined) delete process.env.KADAN_ROTTIE_BIN; else process.env.KADAN_ROTTIE_BIN = before.bin; } }); test('닫은 뒤에도 목록에 남아 있으면 자기 탭만 remove한다', async () => { diff --git a/tests/unit.test.mjs b/tests/unit.test.mjs index 5d1e17c2..75f98634 100644 --- a/tests/unit.test.mjs +++ b/tests/unit.test.mjs @@ -37,7 +37,6 @@ const { buildStopLedgerEntry, buildTmuxCreateArgv, buildRottieCreateArgv, - buildRottieFloorCreateArgv, parseRottieCreateResult, openWindow, tmuxConfText, @@ -158,7 +157,7 @@ test("옛 기록에 by가 없으면 모름이다 — 사람으로 추측하지 assert.match(oldLog, /모름 → b1-작업자\s+card-old/); }); -test("kadan start는 세션에 KADAN_ROLE을 심는다 — tmux -e와 Rottie env 감싸기(card-28)", () => { +test("kadan start는 세션에 KADAN_ROLE을 심는다 — tmux -e(card-28)", () => { // Given: 역할·세션·작업 폴더·원래 명령이 각각 한 요소인 시작 입력이다. const input = { role: "b1-감독", @@ -167,14 +166,9 @@ test("kadan start는 세션에 KADAN_ROLE을 심는다 — tmux -e와 Rottie env cmd: "codex --resume", }; - // When: tmux와 Rottie가 실제로 받을 argv를 만든다. + // When: tmux가 실제로 받을 argv를 만든다. const tmuxArgv = buildTmuxCreateArgv(input); - const rottieArgv = buildRottieFloorCreateArgv({ - ...input, - idempotencyKey: "kadan-kadan-b1-감독-123", - }); - - // Then: tmux는 -e 한 요소, Rottie는 env 명령 인자 배열을 보존한다. + // Then: tmux는 -e 한 요소로 보존한다. assert.deepEqual(tmuxArgv, [ "new-session", "-d", @@ -186,23 +180,7 @@ test("kadan start는 세션에 KADAN_ROLE을 심는다 — tmux -e와 Rottie env "KADAN_ROLE=b1-감독", "codex --resume", ]); - assert.deepEqual(rottieArgv.slice(6, 9), [ - "--command", - "env KADAN_ROLE=b1-감독 codex --resume", - "--idempotency-key", - ]); - const sanitizedRottieArgv = buildRottieFloorCreateArgv({ - role: '감독 "위조"', - session: "kadan-감독-위조", - cwd: input.cwd, - cmd: "/bin/zsh", - idempotencyKey: "kadan-safe-role", - }); - assert.equal( - sanitizedRottieArgv[7], - "env KADAN_ROLE=감독-위조 /bin/zsh" - ); - assert.ok([...tmuxArgv, ...rottieArgv].every((arg) => typeof arg === "string")); + assert.ok(tmuxArgv.every((arg) => typeof arg === "string")); }); test("watch가 보낸 것은 by=watch다 — 감독이 일한 것으로 세지 않는다(2026-08-31 card-27 결함)(card-28)", () => { From e7bde714f76b7f72956db6d6f2d03f62e11cfbfd Mon Sep 17 00:00:00 2001 From: fwkyle Date: Sat, 17 Oct 2026 20:39:43 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20card-center=EC=9D=98=20'=EB=A1=9C?= =?UTF-8?q?=ED=8B=B0=20=EB=B0=94=EB=8B=A5'=20=ED=91=9C=ED=98=84=EC=9D=84?= =?UTF-8?q?=20herdr=20=EB=B0=94=EB=8B=A5=EC=9C=BC=EB=A1=9C=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8=20(=EA=B2=80=EC=88=98=20D1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- docs/card-center.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/card-center.md b/docs/card-center.md index bdc260b4..8dfb4224 100644 --- a/docs/card-center.md +++ b/docs/card-center.md @@ -139,6 +139,6 @@ kadan card update 저장소/카드 --revision 4 --status-reason '검색 방식 ### 터미널 출력 로그 -새 tmux 세션은 원문 바이트를 빠른 gzip 압축으로 `out/<세션>.log.gz`에 뒤이어 보존한다. 같은 역할의 후속 세션은 gzip 멤버를 추가하며 `gzip -dc <절대경로>.log.gz`로 순서대로 복원한다. 실행 중에는 마지막 멤버가 아직 닫히지 않아 복원 명령이 종료부 부족을 표시할 수 있으므로, 완전한 검증은 해당 세션 종료 뒤 한다. gzip이 없으면 경고와 함께 기존 `.log` 기록을 유지한다. SQLite·로티 바닥의 기록 방식과 화면에서 DONE을 판정하는 경로는 바꾸지 않는다. +새 tmux 세션은 원문 바이트를 빠른 gzip 압축으로 `out/<세션>.log.gz`에 뒤이어 보존한다. 같은 역할의 후속 세션은 gzip 멤버를 추가하며 `gzip -dc <절대경로>.log.gz`로 순서대로 복원한다. 실행 중에는 마지막 멤버가 아직 닫히지 않아 복원 명령이 종료부 부족을 표시할 수 있으므로, 완전한 검증은 해당 세션 종료 뒤 한다. gzip이 없으면 경고와 함께 기존 `.log` 기록을 유지한다. SQLite·herdr 바닥의 기록 방식과 화면에서 DONE을 판정하는 경로는 바꾸지 않는다. 기존 `.log`·카드 근거·동결 JSONL은 이동하거나 삭제하지 않는다. 이 변경은 앞으로의 증가량을 줄이며 기존 공간을 회수한 것은 아니다.