SLOPSHOPPER

agent-office

서브에이전트들이 일하는 모습을 게임 속 사무실 평면도로 보여 주고(/office), 부서·직급·연봉·평가 인사 제도로 반복 실수를 다음 업무 지시에 알려 주는 Claude Code mod

paneguardcommandtoastmodeltimer
★ 1v0.16.0MITupdated 2026-10-19hyuny0219/agent-office
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-office
│ ┃ 에이전트 사무실 ✕ › fix the failing auth test and add an audit log call │ ┃ m: 메뉴(인사 명부·보고서·예산·설정) │ ┃ * AGENT OFFICE working 0 meeting 0 done ⏺ Read(src/auth.ts) │ ┃ ▀▀CEO▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀MEETING▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /office │ ┃ ⎿ agent-office: 에이전트 사무실을 열었습니다. 인사 기록은 /office hr, 보고서는 /office r │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 에이전트 사무실
m: 메뉴(인사 명부·보고서·예산·설정) * AGENT OFFICE working 0 meeting 0 done 0 ▀▀CEO▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀MEETING▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀LIBRARY▀▀▀▀▀▀▀ BOSS 보스 대표 · 휴식 · "$ cat" · 도구 9회 · 오류 1 · 0분 ·… 사내 메신저 0초 전 BOSS (혼잣말): Run the test suite 0초 전 BOSS: 앗, Bash 실패 — src/auth.test.ts: 0초 전 BOSS (혼잣말): Show working tree status 0초 전 BOSS (혼잣말): Clean build output and push 0초 전 BOSS (혼잣말): Show environment file
Pane · agent-office-menu
1: 인사 명부 2: 일간 보고 3: 주간 보고 4: 예산 5: 설정 에이전트 사무실 인사 기록 설정: 인사 메모 켜짐 · 교육 효과 측정 켜짐 · 결재 켜짐 · 조직 개편 켜짐 아직 직원이 없습니다. 서브에이전트가 일을 맡으면 부서에 배치됩니다. 제도 • 평가: 완료 +10, 실패 -5(중단 -3), 도구 오류 1개당 -1(최대 -5), 오류 없이 완료 +3, 테스트·빌드 실패한 채 완료 -4, 끝낸 뒤 BOSS가 같은 파일을 다시 고치면 재작업 -4, 결재 반려 -3 • 직급: 사원 0 · 대리 20 · 과장 60 · 차장 120 · 부장 200(경력, 감점이면 깎여 강등). 평균 평가는 최근 일에 무게를 둡니다 • 예산: 없음(넘으면 하루 한 번 알림, /office budget \<부서\> \<달러\>\|off) • 배정: 쉬고 있는 부서 직원 중 비슷한 일을 잘 끝낸 사람·평균 평가가 높은 사람에게 맡깁니다 • 조직 개편: 계속 평가가 나쁘면 권고사직, 오래 일이 없으면 계약 종료(/office reorg on\|off) • 한 사람의 인사카드는 /office hr \<별칭\>, 부서 실적 보고는 /office report \[today\|week\], 메뉴 화면은 /office menu
README

agent-office

한국어 | English

Claude Code에서 서브에이전트들이 일하는 모습을 위에서 내려다본 게임 속 사무실로 보여 주는 mod입니다. 부서·직급·연봉·성과 평가로 이루어진 인사 제도가 있고, 부서가 반복한 실수를 다음 업무 지시에 알려 줍니다.

에이전트들이 일하는 사무실(움직이는 화면)

메인 대화(BOSS)와 기획팀 FOX가 회의실에서 대화하고, 부엉이는 자료실에서 Grep, 매는 서버실에서 테스트를 돌린 뒤 자리로 돌아와 고치고, 늦게 출근한 수달이 입구에서 걸어 들어와 개발팀 자리에 앉습니다. 두루미는 일을 기다리며 탕비실 소파에 있습니다.

사무실 평면도와 패널 목록

설치

터미널 세션의 프롬프트에서:

/plugin install agent-office --marketplace hyuny0219/agent-office

Add marketplace?에 y, 범위는 user(모든 세션)를 고르면 그 세션부터 바로 동작하고, 이후 새로 여는 모든 세션에서 동작합니다.

셸에서:

claude plugin install agent-office --marketplace hyuny0219/agent-office

데스크톱 앱(/plugin이 없는 곳): 프롬프트 옆 + → Plugins → Add marketplace에 hyuny0219/agent-office → Add plugin.

쓰는 법

명령하는 일
/office사무실 평면도 패널 열기
/office menu사무실 메뉴 화면 열기: 인사 명부·일간/주간 보고·예산·설정을 표로
/office hr인사 기록: 부서별 직원·직급·연봉·최근 평가·완료/실패·도구 오류·재작업·근태 알림, 부서별 반복 실수
/office hr <별칭>한 사람의 인사카드: 직급·다음 직급까지 남은 경력, 연봉 내역, 실적, 토큰 누적, 최근 업무 5건, 부서가 반복한 실수 (bear, 곰처럼 영문·한글 어느 쪽으로도, 떠난 직원도)
/office approval on / off결재 켜기/끄기(기본 켜짐): 끝낸 일의 보고를 팀장이 결재하고, 확정한 계획에 기획팀장 검토를 붙임
/office reorg on / off조직 개편 켜기/끄기(기본 켜짐): 계속 평가가 나쁜 직원은 권고사직, 오래 일이 없는 직원은 계약 종료
`/office budget [<부서> <달러>\off]`부서별 하루(최근 24시간) 비용 한도 보기·정하기·없애기 (예: /office budget 개발팀 5)
`/office report [today\week]`일간(최근 24시간, 기본)·주간(최근 7일) 보고: 전체·부서별 실적, 근무 시간과 인건비, 토큰 사용량(부서별·많이 쓴 직원), 결재 집계와 결재자별 정확도, MVP, 살펴볼 일(실패·테스트 실패 완료·재작업·근태 알림·결재 반려)
/office hr on / off업무 지시에 인사 메모, 연발·제자리걸음 때 근태 알림 붙이기 켜기/끄기(기본 켜짐)
/office hr ab on / off교육 효과 측정(실수 목록을 빼고 보내는 비교군 두기) 켜기/끄기(기본 켜짐)

평면도는 터미널에서 픽셀로 그려지고(폭 72~140칸, 높이 27줄), 데스크톱·VS Code에서는 목록만 나옵니다.

사무실 패널의 메뉴 버튼(단축키 m)이나 /office menu로 사무실 메뉴 화면을 따로 엽니다. 위의 1 인사 명부 · 2 일간 보고 · 3 주간 보고 · 4 예산 · 5 설정을 고르면 그 내용을 화면 안에 표로 보입니다(q 닫기). 사무실 패널에서 직원 줄을 누르면 메뉴 화면에 그 직원의 인사카드가 나오고, 인사 명부 아래 별칭 버튼으로도 인사카드를 엽니다(0 명부로). 설정 화면에서는 결재·조직 개편·인사 메모·교육 효과 측정을 바로 켜고 끕니다. 단축키는 그 패널에 포커스가 있을 때 듣습니다.

사무실

  • 위층: 사장실, 회의실, 자료실, 서버실, 화장실 / 가운데 복도(입구·안내 데스크·정수기·자판기) / 아래층: 부서별 책상 섬, 탕비실
  • 일하는 모습은 실제 그대로입니다.
  • 작성·웹·생각 → 자기 부서 책상(모니터가 하는 일에 따라 깜빡임)
  • Read·Grep 등 읽기 → 자료실, Bash → 서버실
  • 서로 대화할 때(띄우며 지시, SendMessage, 끝내고 보고) → 회의실에 모여 말풍선
  • 기다리는 동안(대기) → 탕비실·소파·화장실
  • 완료 → 보고 뒤 입구로 퇴근
  • 새 에이전트는 입구에서 걸어 들어오고, 방을 옮길 때 복도를 걸어갑니다. 메인 대화는 BOSS, 서브에이전트는 동물 별칭(OWL, BEAR, HAWK…)을 받습니다.

사내 대화

  • 말풍선: 머리 위에 3.5초쯤 짧은 말이 뜹니다. 평면도 글자는 영문뿐이라 말풍선도 영문입니다.
  • 혼잣말(회색): edit hr.ts, $ npm test, grep foo
  • 대화(검정): 지시 BEAR, go! → yes!, 메시지 -> OWL, 보고 받기 thanks!
  • 보고(초록) done!, 실수(빨강) oops!
  • 사내 메신저: 평면도 아래에 최근 5줄이 한글로 남습니다.
  • 12초 전 BOSS → BEAR (지시): T139 빌드 — 만들어
  • BEAR (혼잣말): 테스트 돌려 보기: Bash 명령에 붙은 설명
  • BEAR: 앗, Edit 실패 — …
  • OWL → BEAR (메시지): …
  • BEAR → BOSS (보고): 고쳤습니다…
  • 대표 BOSS (결재): BEAR "고치기" 반려 — …
  • 메신저와 말풍선은 터미널 제어 문자를 지우고, 토큰·키·암호처럼 보이는 값은 ***로 가립니다. Bash 말풍선에는 명령 이름만 싣습니다. 메신저는 세션 상태(agent-office.feed)라 같은 세션의 다른 mod도 읽을 수 있습니다.
  • 결재에 쓰는 지시·보고 원문은 패널 상태가 아니라 mod 안에만 둡니다. 그래서 mod를 다시 불러오면 그전에 시작한 일은 보고 없이 끝난 것으로 보여 결재가 보류됩니다.

인사 제도

  • 부서: 에이전트 종류로 정합니다(개발팀·조사팀·품질팀·기획팀·문서팀·지원팀).
  • 직원: 같은 별칭이 한 사람입니다. 기록은 세션을 넘어 남습니다.
  • 배정: 일이 생기면 그 부서에서 쉬고 있는 직원 중 적임자가 맡고, 없으면 채용합니다. 적임자는 평균 평가에 비슷한 일(업무 설명의 낱말이 절반 이상 겹침)을 잘 끝낸 건당 +3(3건까지), 비슷한 일에서 반려·재작업·실패한 건당 −4(2건까지), 경력(최대 +2)을 더해 고릅니다. 고를 사람이 둘 이상이면 까닭("비슷한 일 2건을 잘 끝냄")이 업무 지시 메모와 메신저 지시 줄에 붙습니다.
  • 평가: 실제 기록으로만 매깁니다. 완료 +10, 실패 −5(중단 −3), 도구 오류·거절 1개당 −1(최대 −5), 도구를 써서 오류 없이 완료 +3 → S·A·B·C·D.
  • 검증 실패: 마지막 테스트·빌드 명령(npm test, tsc, pytest 등. grep 같은 다른 명령은 보지 않음)이 실패한 채 완료하면 −4.
  • 재작업: 끝낸 지 10분 안에 BOSS가 그 직원이 고친 파일을 다시 고치면, 그 일의 점수에서 −4(한 일당 한 번).
  • 평균 평가는 최근 일에 무게를 둡니다(최근 한 건이 30%).
  • 직급: 경력 점수로 사원 → 대리(20) → 과장(60) → 차장(120) → 부장(200). 감점을 받으면 경력도 깎여 강등될 수 있습니다. 부서에서 경력 1위가 팀장입니다.
  • 연봉: 직급 기본급 + 평균 평가 성과급 + 팀장 수당.

근태

일하는 모습에서 이상 징후를 찾아 토스트로 알리고, 인사 기록에 근태 알림 횟수를 남깁니다(점수는 깎지 않음).

  • 같은 실수 연발: 같은 도구에서 같은 오류(경로·숫자만 다른 것 포함)가 3번 연달아 나면. 도구마다 따로 세서 사이에 다른 도구를 써도 이어 세고, 그 도구가 성공하면 끊습니다. Bash는 명령 이름으로 가르고(grep의 일치 없음과 npm test 실패는 다른 실수), 사용자·권한 거절은 세지 않습니다.
  • 제자리걸음: 같은 도구를 같은 입력으로 4번 연달아 부르면(TaskGet·Monitor처럼 상태를 묻는 도구는 뺌).
  • 야근: 한 일을 20분 넘게 붙잡고 있으면 한 번 알리고, 목록에 야근을 붙입니다.

/office hr on이면 연발·제자리걸음 때 그 직원이 도구 결과 뒤에 읽는 짧은 알림도 붙입니다. 같은 방법을 그만두고 다른 길을 찾거나, 막혔으면 보고하고 끝내라는 내용입니다.

업무 지시에 붙는 인사 메모

/office hr on(기본)이면 서브에이전트가 뜰 때 업무 지시 끝에 짧은 메모가 붙습니다. 이 메모는 실제 프롬프트에 들어가므로 에이전트의 동작에 영향을 줍니다.

[사내 인사 메모 · agent-office]
담당: 개발팀 BEAR 대리 (팀장 OWL 과장). 지난 기록: 완료 5 · 실패 1 · 도구 오류 6, 최근 평가 A.
개발팀이 최근 반복한 실수입니다. 이번에는 피하세요:
- Edit "String to replace not found in file." 3회 → Edit 전에 바꿀 부분을 Read로 다시 읽어 원문을 그대로 옮긴다
평가 기준: 맡은 일을 끝까지 완료할 것, 도구 오류를 줄일 것, 실패한 테스트·빌드를 두고 끝내지 말 것. 끝낼 때 한 일·확인한 것·남은 위험을 짧게 보고할 것.

반복한 실수는 서브에이전트의 도구 오류 문구를 부서별로 묶어 센 것(경로·숫자는 지움)이고, 2회 이상인 것 중 최근에 난 것부터 최대 3개 붙습니다. 2주 넘게 다시 나지 않은 실수는 붙이지 않습니다. 끄려면 /office hr off. 꺼도 평가·연봉 기록은 계속 남습니다.

결재

도구 호출은 하나도 막지 않습니다. 결재 대상은 끝낸 일의 보고와 확정한 계획입니다.

  • 보고 결재: 서브에이전트가 일을 마치면 그 부서 팀장이 지시·보고·기록(도구 오류, 테스트·빌드 실패, 근태 알림)을 보고 승인하거나 반려합니다. 팀장이 본인이거나 없으면 대표(BOSS)가 결재합니다.
  • 근거: 결재자는 보고 글만이 아니라 사무실이 직접 잰 근거도 봅니다. 근거는 고친 파일(최근 12개), 돌린 테스트·빌드 명령의 이름과 결과(통과·실패·알 수 없음, 출력 마지막 줄), 최근 도구 오류 첫 줄입니다. 보고가 근거와 분명히 어긋나면(통과했다는데 실패, 고쳤다는데 고친 파일 없음, 오류를 숨김) 반려합니다. 명령은 이름만 남기고(인자·환경 변수의 비밀은 남기지 않음), ||·파이프·;·백그라운드로 돌린 명령은 종료 코드를 믿을 수 없어 "알 수 없음"으로 적습니다.
  • 전결: 과장 이상이 올린 보고나 도구를 3번도 안 쓴 짧은 일은 결재 없이 넘어갑니다.
  • 반려: 보완할 점이 토스트로 뜨고, 메인이 그 보고를 받을 때(백그라운드 일이면 다음 도구 결과 뒤에) 함께 읽습니다. 다시 맡길지는 메인이 정합니다. 반려되면 그 일의 평가가 -3, 인사카드·보고서에 반려로 남습니다.
  • 계획 검토: 메인이 계획을 확정하면(ExitPlanMode) 기획팀장이 검증 단계·범위·위험·부서가 반복한 실수 대비를 봅니다. 보완이 필요하면 의견만 붙이고 막지는 않습니다.
  • 결재자는 값싼 모델(haiku)이 맡아 결재 한 건마다 한 번 부릅니다(8초 제한, 처음 부를 때 한 번 안내). 모델이라 틀릴 수 있고, 보고 없이 끝났거나 답하지 못하면 보류로 두고 점수에 반영하지 않습니다. 비용이 싫으면 /office approval off.
  • 결재 정확도: 결재가 맞았는지는 끝낸 뒤 10분 안에 다른 사람(BOSS나 다른 직원)이 같은 파일을 고쳤는지로 잽니다. 승인했는데 고쳐지면 놓친 승인, 반려했는데 아무도 안 고치면 빗나간 반려입니다. 고친 파일이 없는 일(조사·문서 읽기)은 잴 수 없어 빼고, 결재자별로 /office report에 싣습니다. 판정된 반려가 3건 이상인데 맞은 것이 1/3 아래인 결재자의 반려는 감점하지 않고, 적임자를 고를 때도 그르친 일로 치지 않습니다(의견은 그대로 메인에게 갑니다). 같은 직원이 다음 일에서 제 파일을 더 고친 것은 후속 수정으로 치지 않습니다. 후속 수정이 다음 단계 작업일 수도 있어 거친 잣대입니다.

조직 개편

/office reorg on(기본)이면 일이 끝나 평가·결재가 다 끝난 뒤, 그리고 세션을 열 때 직원 명부를 봅니다. 지금 일하는 직원, 일을 배정받아 들어오는 중인 직원, 평가·결재가 덜 끝난 직원은 건드리지 않습니다. 처음 내보낼 때 한 번 안내합니다.

  • 권고사직: 업무 5건 이상인데 최근 평균 평가가 0 아래(D)면.
  • 계약 종료: 업무 2건 이하로 30일 넘게 일이 없으면.

떠난 직원은 토스트와 메신저(인사팀 발령)로 알리고, /office hr의 떠난 직원 목록과 인사카드(/office hr <별칭>)에 남습니다. 떠난 직원의 별칭은 다시 쓰지 않고(기록이 섞이지 않도록), 떠난 뒤에 그 직원의 일이 끝나 들어오면 새로 뽑지 않고 복직시킵니다. 부서는 에이전트 종류로 정해지고 직원은 제 부서 일만 맡으므로, 다른 부서 일을 더 잘하는지 알 수 없어 부서 이동은 하지 않습니다.

보고서

/office report는 끝낸 일 기록(최근 300건)으로 만듭니다. 시간대를 모르므로 "오늘"은 최근 24시간, "이번 주"는 최근 7일입니다. 인건비는 직원의 지금 연봉을 시급(주 40시간 × 52주)으로 바꿔 걸린 시간에 곱한 장난스러운 숫자입니다.

토큰 사용량과 비용

에이전트가 모델을 부를 때마다 엔진이 알려 주는 사용량(입력·출력·캐시 읽기·캐시 쓰기)과, 그 모델의 Anthropic API 정가로 어림한 비용(달러)을 그 에이전트에게 더합니다. 캐시 쓰기는 입력 가격의 1.25배(5분 캐시)로 치고, 하이쿠 5.5의 긴 프롬프트 할증과 배치·지역 가격은 반영하지 않습니다. 가격표에 없는 모델의 토큰은 비용에 넣지 않고 "가격 모름"으로 따로 적습니다(v0.10에서 센 토큰도 모델을 몰라 여기에 듭니다). 구독 요금제라면 실제로 내는 돈이 아니라, 같은 일을 API로 했을 때의 값입니다. 패널 목록 줄에 지금 일의 토큰이 보이고(BOSS는 이 세션 동안 쓴 것), 일이 끝나면 그 일 기록과 직원 누적에 남아 /office hr(누적)·/office hr <별칭>(네 가지 내역·한 건 평균·최근 업무별)·/office report(전체·부서별·많이 쓴 직원 세 명)에 실립니다. 결재자가 그 일을 결재하며 쓴 토큰은 업무 토큰과 따로 "결재"로 셉니다. 적는 수는 네 가지를 모두 더한 것이라 캐시 읽기가 큰 몫을 차지합니다(값이 싼 토큰이니 비용은 괄호 안의 달러를 보세요). BOSS의 토큰은 패널에만 보이고 인사 기록에는 남지 않습니다. 패널 줄에는 그 직원이 쓰는 모델도 짧게 보입니다(마지막으로 답한 모델, 예: Opus 5.5). 줄이 터미널 폭을 넘으면 덜 중요한 칸부터 줄이거나 뺍니다: 말풍선 인용 → 도구 수 → 걸린 시간 → 토큰(비용만 남김) → 한글 별명 → 오류 수 → 비용 → 모델. 누구·어디서·근태 경고·맡은 일은 늘 남깁니다. 계획 검토에 쓴 토큰은 어느 일의 것도 아니어서 세지 않고, 끝난 뒤 이어서 돈 에이전트의 토큰도 세지 않으며, 이 기능 전에 끝낸 일에는 토큰이 없습니다.

부서 예산

/office budget 개발팀 5처럼 부서마다 하루 비용 한도(달러)를 둘 수 있습니다. 최근 24시간에 끝낸 그 부서 일의 비용(일한 것과 그 일의 결재)과 지금 일하는 직원이 쓴 비용을 더해, 한도를 넘으면 토스트와 메신저(인사팀)로 알립니다. 같은 부서는 24시간에 한 번만 알리고, 한도를 새로 정하면 다시 알릴 수 있습니다. 알리기만 하고 일을 막지는 않습니다. 현황은 /office budget과 일간 보고(/office report)에, 정해 둔 한도는 /office hr에 나옵니다. 부서는 개발팀, 개발, dev 어느 쪽으로도 씁니다. BOSS가 쓴 비용은 부서가 없어 어느 예산에도 들지 않습니다.

교육 효과 측정

메모에 붙이는 실수 목록이 정말 재발을 줄이는지 잽니다.

  • 실수 목록을 붙일 업무 5건에 한 건은 목록만 빼고 보냅니다(비교군). 메모의 나머지는 같습니다.
  • 일하는 동안 그 실수가 다시 나면 재발로 적고, 끝나면 실수마다 메모군·비교군의 재발률을 쌓습니다. /office hr에 측정 중: 메모 8건 중 재발 1(13%) / 비교군 3건 중 재발 2(67%)처럼 나옵니다.
  • 메모군 10건·비교군 5건이 모이고 비교군에서 2번 이상 재발해야 판정합니다(드문 실수를 근거 없이 빼지 않도록). 그때 메모를 붙여도 재발률이 낮지 않으면 효과 없음으로 보고 2주 동안 메모에서 뺐다가, 그 뒤 처음부터 다시 잽니다.
  • 비교군을 두고 싶지 않으면 /office hr ab off. 빼 두었던 실수도 다시 붙고, 쌓인 기록은 남습니다.

설정

/config의 agent-office 항목:

항목설명
attachTo다른 mod가 여는 사이드 패널의 id. 적으면 그 패널 아래에도 평면도를 붙입니다(사이드 패널은 한 번에 한 탭만 보이므로, 다른 패널과 위아래로 함께 보고 싶을 때). 비우면 /office 패널에만 그립니다.

개발

claude plugin validate .
claude plugin test .
claude --plugin-dir .

hooks/register.tsx는 엔진의 사건을 잇기만 하고, 일은 나눠 둡니다: store.ts(상태·저장), flow.ts(동기화·평가·결재·근태·조직 개편), view.ts(패널 줄·평면도 칠하기), commands.ts(/office 글 답), menu.ts(메뉴 화면의 메뉴·화면별 내용), table.ts(칸 맞춤 표와 문서 블록: 같은 내용을 글·마크다운으로). 판단은 순수 함수 모듈이 합니다: hr.ts(인사), org.ts(배정·결재 정확도·조직 개편), approval.ts(결재), speech.ts(대화), report.ts(보고서·명부·인사카드), tokens.ts(토큰 사용량·비용), budget.ts(부서 예산), trial.ts(교육 효과), watch.ts(근태), scene.ts(평면도).

README 스크린샷(docs/office.png, docs/office.gif)은 hooks/scene.ts를 그대로 돌려 만듭니다(Pillow 필요).

npx tsx scripts/screenshot-frames.mts > /tmp/frames.json
python3 scripts/render-screenshot.py /tmp/frames.json docs

라이선스

MIT

Source 18 files
hooks/register.tsx 338 lines
1// 에이전트 사무실 mod의 훅. 엔진의 사건(세션 시작·명령·에이전트 띄우기·턴·도구 호출·그리기)을
2// 업무 흐름(flow)·상태(store)·화면(view)·명령(commands)으로 잇는다. 도구 호출은 관찰만 하고 막지 않는다.
3import type { Register } from 'claude-code'
4
5import type { Bubble, Dept, OfficeAgent, TrialTicket, Utterance } from '../types'
6import { briefOf, learn, lessonKey, lessonsFor } from './hr'
7import { checkRunOf, errLineOf, messageOf, oopsOf, orderOf, replyOf, selfTalkOf } from './speech'
8import { enroll, errorLessonKey, markHit } from './trial'
9import { activityOf, layoutOf } from './scene'
10import { answer, COMMAND } from './commands'
11import { awaitApproval, checkBudget, checkFollowUp, checkRework, EDIT_TOOLS, fileOf, isCheckCommand, papers, reorganize, reviewPlan, syncStatus, takeNotes, watch } from './flow'
12import { usageOf } from './tokens'
13import { cardView, flip, MENU, MENU_PANE, MENU_TITLE, nickOfView, TOGGLES, viewBlocks } from './menu'
14import { mdOf } from './table'
15import { isShell } from './watch'
16import { admit, agentsOf, chargeAgent, readMenuView, setMenuView, ensure, fresh, hold, hrOf, MEET_MS, meet, nickOfId, patch, readFeed, release, save, saveHr, speak, staff, watchAgents } from './store'
17import { cellsOf, dropOffice, feedRowsOf, FRAME_MS, markPaneShown, officeAt, RASTER, rowsOf, tick } from './view'
18
19const PANE = 'agent-office'
20
21/** 지금 도는 도구(에이전트 id → 도구 이름들): 하나가 끝나면 남은 것의 활동으로 돌아간다 */
22const inflight = new Map<string, string[]>()
23/** 다른 패널(설정 attachTo) 아래에 붙은 적이 있는지(/office 안내용) */
24let isAttached = false
25
26export const register: Register = (on, options) => {
27  // 설정 attachTo: 다른 mod가 여는 패널의 id. 비어 있지 않으면 그 패널 아래에도 평면도를 붙인다.
28  const attachTo = typeof options.attachTo === 'string' ? options.attachTo.trim() : ''
29
30  on('session.start', async ($, e, next) => {
31    const started = await next(e)
32    await $.command.register(COMMAND)
33    $.clock.every(FRAME_MS, () => void tick($).catch(() => undefined))
34    // 오래 일이 없는 직원은 세션을 열 때 정리한다
35    await reorganize($).catch(() => undefined)
36    return started
37  })
38
39  on('command.run', { command: 'office' }, async ($, e) => {
40    if (e.args.trim().toLowerCase() === 'menu') {
41      const opened = await $.ui.open({ id: MENU_PANE, title: MENU_TITLE, focus: true, closeOnEscape: true })
42      return { text: opened.isPlaced ? '사무실 메뉴를 열었습니다. 1 인사 명부 · 2 일간 보고 · 3 주간 보고 · 4 예산 · 5 설정' : '사무실 메뉴가 아직 그려지지 않았습니다(창이 좁으면 넓혀 주세요).' }
43    }
44    const text = await answer($, e.args)
45    // 설정을 바꾼 명령이면 패널의 켜짐·꺼짐 표시를 다시 그린다(설정은 상태가 아니라 저장소에 있다)
46    if (text !== null) $.ui.invalidate('ui.render')
47    if (text !== null) return { text }
48    await ensure($, 'main')
49    const opened = await $.ui.open({ id: PANE, title: '에이전트 사무실' })
50    const lab = isAttached ? ` 설정한 패널(${attachTo}) 아래에도 붙어 있습니다.` : ''
51    return { text: `${opened.isPlaced ? '에이전트 사무실을 열었습니다.' : '에이전트 사무실이 아직 그려지지 않았습니다(창이 좁으면 넓혀 주세요).'}${lab} 인사 기록은 /office hr, 보고서는 /office report.` }
52  })
53
54  // 새 에이전트: 부서 적임자에게 맡기고(업무 지시 끝에 인사 메모), 띄운 쪽과 회의실에서 지시를 주고받은 뒤 일하러 간다
55  on('agent.spawn', async ($, e, next) => {
56    const { employee, why } = await staff($, e.subagentType, e.description)
57    // 사무실에 들어올 때까지 붙잡아 둔다(그 사이 조직 개편이 고른 직원을 내보내지 않도록)
58    hold(employee.nick)
59    try {
60      const at = await $.clock.now()
61      let brief: string | null = null
62      let trial: TrialTicket | undefined
63      if (e.workflow === undefined && (await hrOf($)).isCoaching) {
64        const lessons = lessonsFor(await hrOf($), employee.dept, at)
65        const keys = lessons.map(lessonKey)
66        let enrolled: ReturnType<typeof enroll> = null
67        await saveHr($, b => {
68          enrolled = enroll(b, keys)
69          return enrolled?.book ?? b
70        })
71        const arm = (enrolled as ReturnType<typeof enroll>)?.arm
72        if (arm !== undefined) trial = { arm, keys, hits: [] }
73        // 비교군은 실수 목록만 뺀다(나머지 메모는 같게 두어 실수 목록의 효과만 본다)
74        brief = briefOf(await hrOf($), employee, at, arm === 'control' ? [] : lessons, why)
75      }
76      const spawned = await next(brief === null ? e : { ...e, prompt: `${e.prompt}\n\n${brief}` })
77      if (spawned.deny !== undefined || spawned.agentId === undefined) return spawned
78      const id = spawned.agentId
79      const parentId = e.parentAgentId ?? 'main'
80      const now = await $.clock.now()
81      await ensure($, parentId)
82      papers.set(id, { task: e.prompt.slice(0, 4000) })
83      // 회의실에서 지시하고("BEAR, go!") 받는다("yes!")
84      const order = orderOf(await nickOfId($, parentId), employee.nick, e.description, e.prompt, now, why)
85      await admit($, { ...fresh(id, e.subagentType, e.description, now), model: spawned.model, parentId, trial, say: replyOf('yes!', now) }, employee.nick,
86        list => patch(parentId, a => ({ ...a, say: order.bubble }))(meet([parentId, id], now + MEET_MS)(list)))
87      await speak($, null, null, order.line)
88      return spawned
89    } finally {
90      release(employee.nick)
91    }
92  })
93
94  on('turn.start', async ($, e, next) => {
95    const id = e.agentId ?? 'main'
96    await ensure($, id)
97    await save($, patch(id, a => ({ ...a, activity: 'think', endedAt: id === 'main' ? null : a.endedAt })))
98    return next(e)
99  })
100
101  // 모델을 부를 때마다 그 에이전트가 쓴 토큰을 더한다(기록이 실패해도 응답은 그대로 넘긴다)
102  on('turn.step', async function* ($, e, next) {
103    const step = yield* next(e)
104    if (step.usage != null) await chargeAgent($, e.agentId ?? 'main', usageOf(step.usage, step.usage.model), step.usage.model).catch(() => undefined)
105    // 직원이 쓴 만큼 부서 예산을 본다(BOSS는 부서가 없다)
106    if (step.usage != null && e.agentId !== undefined) await checkBudget($).catch(() => undefined)
107    return step
108  })
109
110  on('turn.complete', async ($, e, next) => {
111    // 서브에이전트의 마지막 답이 올린 보고다(결재에 쓴다). 끝남이 먼저 보여 평가가 시작돼도 보고가 있도록 먼저 적는다
112    const agentId = e.agentId
113    // 이미 평가를 마친 사람의 늦은 답은 적지 않는다(결재가 끝난 뒤 다시 쌓이지 않도록)
114    const isOpen = agentId !== undefined && ((await agentsOf($)).find(a => a.id === agentId)?.endedAt ?? null) === null
115    if (agentId !== undefined && isOpen && e.answer.trim() !== '') papers.set(agentId, { ...papers.get(agentId), report: e.answer.slice(0, 8000) })
116    const done = await next(e)
117    if (agentId === undefined) await save($, patch('main', a => ({ ...a, activity: 'idle', tool: null })))
118    else await syncStatus($)
119    return done
120  })
121
122  // 관찰만 한다: 기록이 실패해도 도구는 그대로 돈다.
123  on('tool.call', async ($, e, next) => {
124    const id = e.agentId ?? 'main'
125    inflight.set(id, [...(inflight.get(id) ?? []), e.tool])
126    await ensure($, id)
127    const said = await $.clock.now()
128    const me = await nickOfId($, id)
129    // 말풍선은 지금, 메신저 줄은 실제로 돈 뒤에(거절되면 하지 않은 일이다). 지시(Agent)는 띄울 때 따로 말한다
130    let talk: { bubble: Bubble; line: Utterance | null } | null = null
131    let target: OfficeAgent | undefined
132    if (e.tool === 'SendMessage') {
133      // 메시지를 보내면 받는 쪽과 회의실에서 만난다(이름·id·별칭 어느 것으로 불러도 찾는다)
134      const to = String(e.to ?? '').split('@')[0] ?? ''
135      target = (await agentsOf($)).find(a => a.id !== id && (a.name === to || a.id === to || a.nick === to.toUpperCase() || (to === 'main' && a.id === 'main')))
136      talk = messageOf(me, target?.nick ?? (to === '*' ? '전체' : to === '' ? '(알 수 없음)' : to), e, said)
137    } else if (e.tool !== 'Agent') talk = selfTalkOf(me, e, said)
138    // 한 번에 적는다(상태를 쓸 때마다 패널을 다시 그린다)
139    const bubble = talk?.bubble
140    await save($, list => {
141      const moved = patch(id, a => ({ ...a, activity: activityOf(e.tool), tool: e.tool, tools: a.tools + 1, ...(bubble === undefined ? {} : { say: bubble }) }))(list)
142      return target === undefined ? moved : meet([id, target.id], said + MEET_MS)(moved)
143    })
144    let ran: Awaited<ReturnType<typeof next>> | undefined
145    /** 그 직원이 도구 결과 뒤에 읽을 근태 알림 */
146    let nudge: string | null = null
147    try {
148      ran = await next(e)
149    } finally {
150      if (ran !== undefined && ran.deny === undefined && talk?.line != null) await speak($, null, null, talk.line)
151      const left = [...(inflight.get(id) ?? [])]
152      left.splice(left.indexOf(e.tool), 1)
153      inflight.set(id, left)
154      const last = left[left.length - 1]
155      const isError = ran !== undefined && (ran.deny !== undefined || ran.isError === true)
156      // 실제로 고친 파일만 센다(거절·실패한 수정은 고친 게 아니다)
157      const file = ran !== undefined && !isError && EDIT_TOOLS.has(e.tool) ? fileOf(e) : null
158      // 테스트·빌드 명령의 결과만 본다(grep의 일치 없음 같은 건 검증 실패가 아니다). 거절은 결과가 아니니 넘긴다
159      const isCheck = isShell(e.tool) && ran !== undefined && ran.deny === undefined && isCheckCommand(e)
160      // 실험 중인 실수가 다시 났으면 오류를 세는 바로 이 저장에서 적는다(곧 끝나 집계돼도 빠지지 않도록)
161      const hitKey = isError && ran !== undefined ? errorLessonKey(e.tool, ran.deny ?? ran.text ?? '') : null
162      // 실험 재발 표시와 활동·오류 수를 한 번에 적는다(상태를 쓸 때마다 패널을 다시 그린다)
163      await save($, patch(id, x => {
164        const a = x.trial !== undefined && hitKey !== null ? { ...x, trial: markHit(x.trial, hitKey) } : x
165        return a.activity === 'done' || a.activity === 'failed' ? a : {
166        ...a, activity: last === undefined ? 'think' : activityOf(last), tool: last ?? a.tool, errors: (a.errors ?? 0) + (isError ? 1 : 0),
167        isCheckFailed: isCheck ? ran?.isError === true : a.isCheckFailed,
168        // 결재 근거: 돌린 확인 명령과 그 결과, 난 오류(비밀은 가려 둔다)
169        // (BOSS는 결재받지 않으니 쌓지 않는다)
170        checks: isCheck && ran !== undefined && id !== 'main' ? [...(a.checks ?? []), checkRunOf(e, ran.isError === true, ran.text ?? '')].slice(-3) : a.checks,
171        errLines: isError && ran !== undefined && id !== 'main' ? [...(a.errLines ?? []), errLineOf(e.tool, ran.deny ?? ran.text ?? '')].slice(-3) : a.errLines,
172        files: file === null || id === 'main' || (a.files ?? []).includes(file) ? a.files : [...(a.files ?? []), file].slice(-50),
173        }
174      }))
175      if (file !== null) await checkFollowUp($, file, me)
176      if (file !== null && id === 'main') await checkRework($, file)
177      // 서브에이전트의 오류는 부서의 교훈으로 남긴다
178      const who = (await agentsOf($)).find(a => a.id === id)
179      // 실패하면 "oops!"(거절은 그 사람 실수가 아니니 말하지 않는다)
180      if (ran !== undefined && ran.deny === undefined && ran.isError === true && who !== undefined) {
181        const o = oopsOf(who.nick, e.tool, ran.text ?? '', await $.clock.now())
182        await speak($, id, o.bubble, o.line)
183      }
184      if (isError && who?.dept !== undefined && ran !== undefined) {
185        const text = ran.deny ?? ran.text ?? ''
186        const dept: Dept = who.dept
187        const at = await $.clock.now()
188        await saveHr($, b => learn(b, dept, e.tool, text, at))
189      }
190      if (ran !== undefined && id !== 'main') nudge = await watch($, id, e, ran)
191    }
192    // 여기 오면 next가 답했다(던졌으면 finally 뒤로 나간다)
193    const out = ran as NonNullable<typeof ran>
194    if (out.deny !== undefined) return out
195    const extra: string[] = nudge === null ? [] : [nudge]
196    const rec = (out as { result?: unknown }).result
197    const status = typeof rec === 'object' && rec !== null ? (rec as { status?: unknown; agentId?: unknown }) : {}
198    // 기다려서 받은 서브에이전트의 보고는 결재를 마치고 돌려준다(반려면 그 의견을 붙여)
199    if (e.tool === 'Agent' && out.isError !== true && status.status === 'completed' && typeof status.agentId === 'string') {
200      await awaitApproval($, status.agentId, next.budget.remainingMs)
201    }
202    if (e.tool === 'ExitPlanMode' && id === 'main' && out.isError !== true) {
203      const plan = (status as { plan?: unknown }).plan
204      const note = typeof plan === 'string' ? await reviewPlan($, plan).catch(() => null) : null
205      if (note !== null) extra.push(note)
206    }
207    extra.push(...takeNotes(id, await agentsOf($)))
208    if (extra.length === 0) return out
209    return { ...out, context: [...(out.context ?? []), ...extra] }
210  })
211
212  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
213    await watchAgents($)
214    markPaneShown()
215    const feed = await readFeed($)
216    const agents = await agentsOf($)
217    const b = await hrOf($)
218    const now = await $.clock.now()
219    const lines = rowsOf(agents, b, now, e.surface === 'terminal' ? (e.viewport?.columns ?? 80) - 2 : Infinity)
220    const feedLines = feedRowsOf(feed, now)
221
222    const { Box, Text, Button } = $.ui.resolve(e)
223    // 메뉴 화면은 따로 연다. 직원 줄을 누르면 메뉴 화면에 그 직원의 인사카드를 띄운다
224    const openMenu = async (view?: string) => {
225      if (view !== undefined) await setMenuView($, view)
226      await $.ui.open({ id: MENU_PANE, title: MENU_TITLE, focus: true, closeOnEscape: true })
227    }
228    const menu = (
229      <Box>
230        <Button key="open-menu" hotkey="m" plain onPress={() => openMenu().catch(() => undefined)}>메뉴(인사 명부·보고서·예산·설정)</Button>
231      </Box>
232    )
233    const isTerminal = e.surface === 'terminal'
234    const rowEls = lines.map(l => (
235      <Box key={l.nick === null ? l.key : `${l.key}-line`}>
236        {l.nick === null
237          ? <Text dimColor={l.isOff} color={l.isBad ? 'red' : undefined} wrap={isTerminal ? 'truncate-end' : undefined}>{l.text}</Text>
238          : <Button key={l.key} plain onPress={() => openMenu(cardView(l.nick ?? '')).catch(() => undefined)}>
239              <Text dimColor={l.isOff} color={l.isBad ? 'red' : undefined} wrap={isTerminal ? 'truncate-end' : undefined}>{l.text}</Text>
240            </Button>}
241      </Box>
242    ))
243
244    if (!isTerminal) {
245      dropOffice(PANE)
246      return (
247        <Box flexDirection="column">
248          {menu}
249          <Text dimColor>사무실 평면도는 터미널에서만 그려집니다. 직원 줄을 누르면 메뉴 화면에 인사카드가 나옵니다.</Text>
250          {rowEls}
251          {feedLines.length > 0 && <Text bold>사내 메신저</Text>}
252          {feedLines.map(l => <Box key={l.key}><Text color={l.color} wrap="truncate-end">{l.text}</Text></Box>)}
253        </Box>
254      )
255    }
256
257    const { Raster } = $.ui.resolve(e)
258    const office = officeAt(PANE, layoutOf(e.viewport?.columns ?? 80))
259    const cells = cellsOf(office, agents, now, b)
260    return (
261      <Box flexDirection="column">
262        {menu}
263        <Raster key={RASTER} columns={office.layout.columns} rows={office.layout.rows} cells={cells} />
264        {lines.length === 0 && <Text dimColor>아직 일하는 에이전트가 없습니다.</Text>}
265        {rowEls}
266        {feedLines.length > 0 && <Box marginTop={1}><Text bold>사내 메신저</Text></Box>}
267        {feedLines.map(l => <Box key={l.key}><Text color={l.color} wrap="truncate-end">{l.text}</Text></Box>)}
268      </Box>
269    )
270  })
271
272  // 사무실 메뉴 화면: 위에 1~5 메뉴, 아래에 고른 것을 표(마크다운)로. 설정은 켜고 끄는 버튼으로
273  on('ui.render', { component: 'Pane', requestId: MENU_PANE }, async ($, e) => {
274    // 사람이 바뀌면 다시 그리고(구독), 저장소(인사 기록)는 구독할 수 없어 사무실 패널처럼 5초마다 다시 그린다
275    await watchAgents($)
276    markPaneShown()
277    const view = await readMenuView($)
278    const b = await hrOf($)
279    const blocks = await viewBlocks($, b, view)
280    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
281    const go = (v: string) => () => setMenuView($, v).catch(() => undefined)
282    const card = nickOfView(view)
283    return (
284      <Box flexDirection="column">
285        <Box columnGap={2} flexWrap="wrap">
286          {MENU.map(m => <Button key={m.key} hotkey={m.hotkey} plain dimColor={view !== m.view} onPress={go(m.view)}>{m.label}</Button>)}
287          <Button key="menu-close" hotkey="q" plain dimColor role="dismiss" onPress={() => $.ui.close({ id: MENU_PANE }).catch(() => undefined)}>닫기</Button>
288        </Box>
289        <Box marginTop={1} flexDirection="column">
290          <Markdown key="menu-body" text={mdOf(blocks)} />
291        </Box>
292        {view === 'settings' && (
293          <Box flexDirection="column">
294            {TOGGLES.map(t => (
295              <Box key={`${t.key}-line`} columnGap={2}>
296                <Button key={t.key} plain onPress={() => flip($, t.key).catch(() => undefined)}>{`${t.isOn(b) ? '[켜짐]' : '[꺼짐]'} ${t.label}`}</Button>
297                <Text dimColor>{t.note}</Text>
298              </Box>
299            ))}
300          </Box>
301        )}
302        {view === 'hr' && b.employees.length + b.retired.length > 0 && (
303          <Box marginTop={1} columnGap={2} flexWrap="wrap">
304            <Text dimColor>인사카드:</Text>
305            {b.employees.map(x => <Button key={`card-${x.nick}`} plain onPress={go(cardView(x.nick))}>{x.nick}</Button>)}
306            {b.retired.slice(0, 5).map(x => <Button key={`card-${x.nick}`} plain dimColor onPress={go(cardView(x.nick))}>{`${x.nick}(퇴사)`}</Button>)}
307          </Box>
308        )}
309        {card !== null && (
310          <Box marginTop={1}>
311            <Button key="card-back" hotkey="0" plain onPress={go('hr')}>← 인사 명부</Button>
312          </Box>
313        )}
314      </Box>
315    )
316  })
317
318  // 설정한 패널 아래에 사무실 평면도를 붙인다. 그 패널의 화면은 그 mod가 그린 그대로 둔다.
319  if (attachTo === '' || attachTo === PANE) return
320  on('ui.render', { component: 'Pane', requestId: attachTo }, async ($, e, next) => {
321    const host = await next(e)
322    if (e.surface !== 'terminal') return host
323    isAttached = true
324    const { Box, Raster } = $.ui.resolve(e)
325    // 상태를 read하지 않는다: 도구 호출마다 랩 화면까지 다시 그리지 않도록 사본만 본다
326    const office = officeAt(attachTo, layoutOf(e.viewport?.columns ?? 80))
327    const cells = cellsOf(office, await agentsOf($), await $.clock.now(), await hrOf($))
328    return (
329      <Box flexDirection="column">
330        {host}
331        <Box marginTop={1}>
332          <Raster key={RASTER} columns={office.layout.columns} rows={office.layout.rows} cells={cells} />
333        </Box>
334      </Box>
335    )
336  })
337}
338
hooks/hr.ts 362 lines
1// 인사 제도. 에이전트 종류로 부서를 정하고, 같은 부서 직원(별칭)이 일을 맡을 때마다 경력이 쌓인다.
2// 평가는 실제 기록(완료·실패·도구 오류·거절)으로만 매기고, 부서가 반복한 실수는 "교훈"으로 모아
3// 다음에 그 부서 직원이 일을 맡을 때 업무 지시 끝에 짧은 인사 메모로 붙인다. 순수 함수만 둔다.
4import type { Approval, Dept, Employee, Handoff, HrBook, JobRecord, Lesson, Retiree } from '../types'
5import { tokensOf } from './tokens'
6
7export const DEPTS: Dept[] = ['dev', 'research', 'qa', 'plan', 'docs', 'ops']
8
9export const DEPT_INFO: Record<Dept, { ko: string; en: string; color: number }> = {
10  dev: { ko: '개발팀', en: 'DEV', color: 0x5c7cfa },
11  research: { ko: '조사팀', en: 'RESEARCH', color: 0x20c997 },
12  qa: { ko: '품질팀', en: 'QA', color: 0xff6b6b },
13  plan: { ko: '기획팀', en: 'PLANNING', color: 0xfcc419 },
14  docs: { ko: '문서팀', en: 'DOCS', color: 0xcc5de8 },
15  ops: { ko: '지원팀', en: 'OPS', color: 0xadb5bd },
16}
17
18const DEPT_OF: Record<string, Dept> = {
19  builder: 'dev', executor: 'dev', 'general-purpose': 'dev', debugger: 'dev', 'code-simplifier': 'dev',
20  'test-engineer': 'dev', designer: 'dev', fork: 'dev',
21  explore: 'research', 'document-specialist': 'research', scientist: 'research', tracer: 'research',
22  'claude-code-guide': 'research', research: 'research',
23  reviewer: 'qa', 'code-reviewer': 'qa', verifier: 'qa', critic: 'qa', 'security-reviewer': 'qa', 'qa-tester': 'qa',
24  plan: 'plan', planner: 'plan', architect: 'plan', analyst: 'plan',
25  writer: 'docs',
26}
27
28/** 'oh-my-claudecode:executor' → 'dev' */
29export const deptOf = (type: string): Dept => DEPT_OF[(type.split(':').pop() ?? type).toLowerCase()] ?? 'ops'
30
31export const RANKS = [
32  { ko: '사원', xp: 0, salary: 3600 },
33  { ko: '대리', xp: 20, salary: 4200 },
34  { ko: '과장', xp: 60, salary: 5000 },
35  { ko: '차장', xp: 120, salary: 6000 },
36  { ko: '부장', xp: 200, salary: 7200 },
37] as const
38
39export const rankOf = (xp: number) => [...RANKS].reverse().find(r => xp >= r.xp) ?? RANKS[0]
40
41/** 근태 알림을 한 번 받았다 */
42export const warn = (book: HrBook, nick: string): HrBook =>
43  ({ ...book, employees: book.employees.map(e => (e.nick === nick ? { ...e, alarms: e.alarms + 1 } : e)) })
44
45/** 부서 팀장: 일을 한 번이라도 한 직원 중 경력(xp)이 가장 높은 사람 */
46export const leadOf = (book: HrBook, dept: Dept): Employee | undefined =>
47  book.employees.filter(e => e.dept === dept && e.jobs > 0).sort((a, b) => b.xp - a.xp || a.hiredAt - b.hiredAt)[0]
48
49/** 성과급(만원): 최근 평가 평균 × 60 */
50export const bonusOf = (e: Employee): number => Math.max(0, Math.round((e.avgScore ?? 0) * 60))
51/** 팀장 수당(만원) */
52export const LEAD_ALLOWANCE = 600
53
54/** 연봉(만원): 직급 기본급 + 최근 평가 성과급 + 팀장 수당 */
55export const salaryOf = (book: HrBook, e: Employee): number =>
56  rankOf(e.xp).salary + bonusOf(e) + (leadOf(book, e.dept)?.nick === e.nick ? LEAD_ALLOWANCE : 0)
57
58export type JobResult = {
59  isDone: boolean
60  isKilled: boolean
61  errors: number
62  /** 부른 도구 수(없으면 일을 안 한 것으로 보고 무오류 가산을 주지 않는다) */
63  tools?: number
64  /** 마지막 Bash(테스트·빌드)가 실패한 채 끝냈는지 */
65  isCheckFailed?: boolean
66}
67
68/** 실패한 테스트·빌드를 두고 완료하면, 끝낸 뒤 BOSS가 같은 파일을 다시 고치면 깎는 점수 */
69export const CHECK_PENALTY = 4
70export const REWORK_PENALTY = 4
71
72/**
73 * 한 건의 평가 점수: 완료 10, 실패 -5(중단 -3), 도구 오류 1개당 -1(최대 -5),
74 * 도구를 써서 오류 없이 완료하면 +3, 마지막 테스트·빌드가 실패한 채 완료하면 -4
75 */
76export const scoreOf = (r: JobResult): number => {
77  const base = r.isDone ? 10 : r.isKilled ? -3 : -5
78  const isClean = r.isDone && r.errors === 0 && (r.tools ?? 1) > 0 && r.isCheckFailed !== true
79  return base - Math.min(5, r.errors) + (isClean ? 3 : 0) - (r.isDone && r.isCheckFailed === true ? CHECK_PENALTY : 0)
80}
81
82export const gradeOf = (score: number): string => (score >= 13 ? 'S' : score >= 10 ? 'A' : score >= 6 ? 'B' : score >= 0 ? 'C' : 'D')
83
84export const emptyBook = (): HrBook => ({ version: 2, employees: [], lessons: {}, handoffs: [], isCoaching: true, isTrialing: true, trialSeq: 0, trials: {}, log: [], isApproving: true, isApprovalNoticed: false, isReorganizing: true, isReorgNoticed: false, retired: [], budgets: {}, budgetNotices: {} })
85
86/**
87 * 저장된 인사 기록을 지금 형식으로 옮긴다. 예전 판(version 1)이나 모르는 판이라도 직원 명부가 있으면
88 * 빠진 칸만 채워 살린다(기록을 비우지 않는다). 명부가 없으면 null.
89 */
90export function migrate(stored: unknown): HrBook | null {
91  if (typeof stored !== 'object' || stored === null) return null
92  const v = stored as Record<string, unknown>
93  if (!Array.isArray(v.employees)) return null
94  const num = (x: unknown, or: number): number => (typeof x === 'number' && Number.isFinite(x) ? x : or)
95  const numOrNull = (x: unknown): number | null => (typeof x === 'number' && Number.isFinite(x) ? x : null)
96  const isDept = (x: unknown): x is Dept => DEPTS.includes(x as Dept)
97  const employeeOf = ({ tokens, tokenJobs, ...e }: Record<string, unknown>): Employee => ({
98    // 더 새로운 판이 둔 칸은 그대로 남긴다
99    ...(e as object),
100    ...(tokensOf(tokens) === undefined ? {} : { tokens: tokensOf(tokens), tokenJobs: Math.max(1, Math.floor(num(tokenJobs, 1))) }),
101    nick: e.nick as string, dept: isDept(e.dept) ? e.dept : 'ops', type: typeof e.type === 'string' ? e.type : 'agent',
102    xp: num(e.xp, 0), jobs: num(e.jobs, 0), done: num(e.done, 0), failed: num(e.failed, 0), toolErrors: num(e.toolErrors, 0),
103    reworks: num(e.reworks, 0), alarms: num(e.alarms, 0), rejections: num(e.rejections, 0), avgScore: numOrNull(e.avgScore), lastScore: numOrNull(e.lastScore), hiredAt: num(e.hiredAt, 0), lastAt: numOrNull(e.lastAt),
104  })
105  const isNamed = (e: unknown): e is Record<string, unknown> => typeof e === 'object' && e !== null && typeof (e as { nick?: unknown }).nick === 'string'
106  const employees = (v.employees as unknown[]).filter(isNamed).map(employeeOf)
107  const retired = (Array.isArray(v.retired) ? v.retired as unknown[] : []).filter(isNamed)
108    .filter(e => typeof e.retiredAt === 'number' && Number.isFinite(e.retiredAt))
109    .map((e): Retiree => ({ ...employeeOf(e), retiredAt: e.retiredAt as number, reason: typeof e.reason === 'string' ? e.reason : '' }))
110    .slice(0, MAX_RETIRED)
111  const rawLessons = typeof v.lessons === 'object' && v.lessons !== null ? v.lessons as Record<string, unknown> : {}
112  const lessons: HrBook['lessons'] = {}
113  for (const d of DEPTS) {
114    const list = rawLessons[d]
115    if (!Array.isArray(list)) continue
116    lessons[d] = list.filter((l): l is Lesson => typeof l?.tool === 'string' && typeof l.pattern === 'string' && typeof l.count === 'number' && typeof l.lastAt === 'number')
117  }
118  const handoffs = (Array.isArray(v.handoffs) ? v.handoffs : []).filter((x): x is Handoff =>
119    typeof x?.agentId === 'string' && typeof x.nick === 'string' && Array.isArray(x.files) && typeof x.endedAt === 'number' && typeof x.job === 'number')
120  const arm = (x: unknown) => {
121    const a = (typeof x === 'object' && x !== null ? x : {}) as Record<string, unknown>
122    return { jobs: num(a.jobs, 0), hits: num(a.hits, 0) }
123  }
124  const trials: HrBook['trials'] = {}
125  for (const [k, x] of Object.entries(typeof v.trials === 'object' && v.trials !== null ? v.trials as Record<string, unknown> : {})) {
126    if (typeof x !== 'object' || x === null) continue
127    const s = x as Record<string, unknown>
128    const at = numOrNull(s.ineffectiveAt)
129    trials[k] = { memo: arm(s.memo), control: arm(s.control), lastAt: num(s.lastAt, 0), ...(at === null ? {} : { ineffectiveAt: at }) }
130  }
131  const isNum = (x: unknown): x is number => typeof x === 'number' && Number.isFinite(x)
132  /** 부서 → 수(모르는 부서·수가 아닌 값·조건에 맞지 않는 값은 버린다) */
133  const perDept = (x: unknown, isOk: (n: number) => boolean): Partial<Record<Dept, number>> => {
134    const o = typeof x === 'object' && x !== null ? x as Record<string, unknown> : {}
135    return Object.fromEntries(DEPTS.flatMap(d => (isNum(o[d]) && isOk(o[d]) ? [[d, o[d]]] : [])))
136  }
137  const log = (Array.isArray(v.log) ? v.log : []).filter((x): x is JobRecord =>
138    typeof x?.nick === 'string' && isNum(x.job) && isNum(x.startedAt) && isNum(x.endedAt) && isNum(x.score)
139    && (x.outcome === 'done' || x.outcome === 'failed' || x.outcome === 'killed'))
140    .map(x => {
141      const { salary, approval, approver, files, isFollowedUp, isWaived, tokens, reviewTokens, ...rest } = x
142      const used = tokensOf(tokens)
143      const reviewed = tokensOf(reviewTokens)
144      const isApproval = approval === 'approved' || approval === 'rejected' || approval === 'exempt'
145      return { ...rest, ...(isApproval ? { approval } : {}), dept: isDept(x.dept) ? x.dept : 'ops', description: typeof x.description === 'string' ? x.description : '', errors: num(x.errors, 0),
146        isCheckFailed: x.isCheckFailed === true, isReworked: x.isReworked === true, alarms: num(x.alarms, 0), ...(isNum(salary) ? { salary } : {}),
147        ...(typeof approver === 'string' ? { approver } : {}), ...(isNum(files) ? { files } : {}), ...(isFollowedUp === true ? { isFollowedUp } : {}), ...(isWaived === true ? { isWaived } : {}),
148        ...(used === undefined ? {} : { tokens: used }), ...(reviewed === undefined ? {} : { reviewTokens: reviewed }) }
149    })
150    .slice(0, MAX_LOG)
151  return {
152    ...v, version: 2, employees, lessons, handoffs, trials, log, retired,
153    isCoaching: v.isCoaching !== false, isTrialing: v.isTrialing !== false, isApproving: v.isApproving !== false, isApprovalNoticed: v.isApprovalNoticed === true, trialSeq: num(v.trialSeq, 0),
154    isReorganizing: v.isReorganizing !== false, isReorgNoticed: v.isReorgNoticed === true,
155    budgets: perDept(v.budgets, x => x > 0), budgetNotices: perDept(v.budgetNotices, () => true),
156  }
157}
158
159/** 최근 평가 가중 평균: 최근 일이 30%를 차지한다 */
160const EWMA = 0.3
161const blend = (avg: number | null, score: number): number => (avg === null ? score : avg * (1 - EWMA) + score * EWMA)
162
163type Moved = { book: HrBook; promotedTo: string | null; demotedTo: string | null }
164
165/** 한 직원의 경력·평가를 고치고, 직급이 바뀌었으면 알려 준다(경력은 깎이면 강등된다) */
166function adjust(book: HrBook, nick: string, fn: (e: Employee) => Employee): Moved {
167  let promotedTo: string | null = null
168  let demotedTo: string | null = null
169  const employees = book.employees.map(e => {
170    if (e.nick !== nick) return e
171    const next = fn(e)
172    const before = rankOf(e.xp)
173    const after = rankOf(next.xp)
174    if (after.xp > before.xp) promotedTo = after.ko
175    if (after.xp < before.xp) demotedTo = after.ko
176    return next
177  })
178  return { book: { ...book, employees }, promotedTo, demotedTo }
179}
180
181/** 일 한 건을 인사 기록에 반영하고, 승진·강등했으면 새 직급을 돌려준다 */
182export function review(book: HrBook, nick: string, r: JobResult, now: number): Moved & { score: number } {
183  const score = scoreOf(r)
184  const moved = adjust(book, nick, e => ({
185    ...e, xp: Math.max(0, e.xp + score), jobs: e.jobs + 1, lastAt: now, lastScore: score, avgScore: blend(e.avgScore, score),
186    done: e.done + (r.isDone ? 1 : 0), failed: e.failed + (r.isDone ? 0 : 1), toolErrors: e.toolErrors + r.errors,
187  }))
188  return { ...moved, score }
189}
190
191/** 끝낸 일 기록은 최근 것 300건까지 */
192export const MAX_LOG = 300
193/** 떠난 직원은 최근 50명까지 */
194export const MAX_RETIRED = 50
195
196/** 끝낸 일 한 건을 기록한다(보고서·인사카드가 읽는다) */
197export const record = (book: HrBook, r: JobRecord): HrBook => ({ ...book, log: [r, ...book.log].slice(0, MAX_LOG) })
198
199// ── 재작업: 끝낸 일의 파일을 BOSS가 곧 다시 고치면 그 직원의 평가를 깎는다 ─────
200
201/** 끝낸 뒤 이 시간 안에 BOSS가 같은 파일을 고치면 재작업 */
202export const REWORK_MS = 10 * 60_000
203
204/** 끝낸 일의 파일을 남긴다(최근 20건) */
205export function handOff(book: HrBook, h: Handoff): HrBook {
206  if (h.files.length === 0) return book
207  return { ...book, handoffs: [h, ...book.handoffs.filter(x => x.agentId !== h.agentId)].slice(0, 20) }
208}
209
210/**
211 * BOSS가 파일을 고쳤다: 최근 끝낸 일 중 그 파일을 고친 것이 있으면 재작업으로 깎는다.
212 * 한 일은 한 번만 깎고, 오래된 인계 기록은 버린다.
213 */
214export function rework(book: HrBook, file: string, now: number): Moved & { nick: string | null } {
215  const live = book.handoffs.filter(h => now - h.endedAt <= REWORK_MS)
216  const hit = live.find(h => h.files.includes(file))
217  const kept = { ...book, handoffs: live.filter(h => h !== hit) }
218  if (hit === undefined) return { book: kept, promotedTo: null, demotedTo: null, nick: null }
219  if (!kept.employees.some(e => e.nick === hit.nick)) return { book: kept, promotedTo: null, demotedTo: null, nick: null }
220  const moved = dock(kept, hit.nick, hit.job, REWORK_PENALTY, e => ({ ...e, reworks: e.reworks + 1 }), r => ({ ...r, isReworked: true }))
221  return { ...moved, nick: hit.nick }
222}
223
224/**
225 * 끝낸 일 한 건(그 직원의 job번째 일)의 평가를 나중에 깎는다: 경력, 그 일이 마지막이면 최근 평가,
226 * 평균 속 그 일의 몫, 그 일의 기록. mark·markLog로 횟수와 기록 표시를 더한다.
227 */
228function dock(book: HrBook, nick: string, job: number, amount: number, mark: (e: Employee) => Employee, markLog: (r: JobRecord) => JobRecord): Moved {
229  const moved = adjust(book, nick, e => {
230    // 그 일 뒤에 일을 더 했으면 그만큼 평균 속 그 일의 무게가 줄었다
231    const later = Math.max(0, e.jobs - job)
232    const isLatest = later === 0
233    return mark({
234      ...e, xp: Math.max(0, e.xp - amount),
235      lastScore: isLatest && e.lastScore !== null ? e.lastScore - amount : e.lastScore,
236      avgScore: e.avgScore === null ? null : e.avgScore - amount * EWMA * (1 - EWMA) ** later,
237    })
238  })
239  const log = moved.book.log.map(r => (r.nick === nick && r.job === job ? markLog({ ...r, score: r.score - amount }) : r))
240  return { ...moved, book: { ...moved.book, log } }
241}
242
243/** 결재 결과(와 결재자)를 그 일의 기록에 적고, 반려면 깎는다(감점 0이면 유예한 반려로 적는다) */
244export function decide(book: HrBook, nick: string, job: number, approval: Approval, penalty: number, approver?: string): Moved {
245  const isWaived = approval === 'rejected' && penalty === 0
246  const mark = (r: JobRecord): JobRecord => ({ ...r, approval, ...(approver === undefined ? {} : { approver }), ...(isWaived ? { isWaived } : {}) })
247  if (approval !== 'rejected') {
248    return { book: { ...book, log: book.log.map(r => (r.nick === nick && r.job === job ? mark(r) : r)) }, promotedTo: null, demotedTo: null }
249  }
250  return dock(book, nick, job, penalty, e => ({ ...e, rejections: e.rejections + 1 }), mark)
251}
252
253/**
254 * 일을 맡길 직원: 그 부서에서 지금 일하지 않는 직원 중 경력이 가장 높은 사람.
255 * 없으면 새로 뽑는다(별칭은 hire가 정한다).
256 */
257export function assign(book: HrBook, dept: Dept, busy: ReadonlySet<string>): Employee | undefined {
258  return book.employees.filter(e => e.dept === dept && !busy.has(e.nick)).sort((a, b) => b.xp - a.xp || a.hiredAt - b.hiredAt)[0]
259}
260
261export function hire(book: HrBook, nick: string, dept: Dept, type: string, now: number): { book: HrBook; employee: Employee } {
262  const employee: Employee = { nick, dept, type, xp: 0, jobs: 0, done: 0, failed: 0, toolErrors: 0, reworks: 0, alarms: 0, rejections: 0, avgScore: null, lastScore: null, hiredAt: now, lastAt: null }
263  return { book: { ...book, employees: [...book.employees, employee] }, employee }
264}
265
266// ── 교훈: 도구 오류 문구를 모아 다음 업무 지시에 붙인다 ─────────────
267
268const TIPS: [RegExp, string][] = [
269  [/string to replace not found|old_string/i, 'Edit 전에 바꿀 부분을 Read로 다시 읽어 원문을 그대로 옮긴다'],
270  [/has not been read|read it first|must read/i, 'Edit·Write 전에 그 파일을 먼저 Read한다'],
271  [/modified since|changed on disk/i, '파일이 바뀌었으니 다시 Read한 뒤 고친다'],
272  [/found \d+ matches|not unique|multiple matches/i, 'Edit의 old_string을 주변 줄까지 넣어 하나로 좁힌다'],
273  [/no matches found/i, 'zsh 글롭은 따옴표로 감싸거나 일치 없음에 대비한다'],
274  // 윈도우 PowerShell: Bash 명령(grep·head·ls -la)이나 &&를 쓰면 이렇게 실패한다
275  [/is not recognized as|is not a valid statement separator|parameter cannot be found that matches/i, 'PowerShell에서는 Bash 명령 대신 Select-String·Get-ChildItem·Get-Content를 쓰고, 명령은 ;로 잇는다'],
276  [/command not found/i, '명령 경로를 확인하고(node_modules/.bin 등) 그 경로로 부른다'],
277  [/timed out|timeout/i, '오래 걸리는 명령은 범위를 줄이거나 백그라운드로 돌린다'],
278  [/enoent|no such file|does not exist/i, '경로를 Glob·ls로 먼저 확인한다'],
279  [/denied|not allowed|blocked|refus/i, '막힌 명령은 같은 방식으로 다시 시도하지 말고 다른 길을 찾거나 보고한다'],
280  [/exit code [1-9]|failed/i, '명령이 실패하면 출력의 마지막 줄을 읽고 원인부터 고친다'],
281]
282
283/** 오류 문구를 같은 종류끼리 묶을 수 있게 다듬는다(경로·숫자·따옴표 안 내용 제거, 윈도우 경로 C:\a\b도) */
284export const patternOf = (text: string): string =>
285  (text.split('\n').find(l => l.trim() !== '') ?? '')
286    .replace(/(\b[A-Za-z]:)?([\\/][\w.@-]+)+/g, '<경로>')
287    .replace(/(["'`]).*?\1/g, '"…"')
288    .replace(/\d+/g, '#')
289    .trim()
290    .slice(0, 80)
291
292export const tipOf = (pattern: string): string | null => TIPS.find(([re]) => re.test(pattern))?.[1] ?? null
293
294const MAX_LESSONS = 8
295/** 이 기간 동안 다시 나지 않은 실수는 업무 지시 메모에 붙이지 않는다 */
296export const LESSON_TTL_MS = 14 * 24 * 60 * 60_000
297
298/**
299 * 부서의 교훈에 오류 하나를 더한다(같은 도구·문구면 횟수만). 부서마다 8개까지 두고,
300 * 넘치면 가장 오래 안 난 것을 버린다: 횟수로 자르면 자주 난 옛 실수가 자리를 막아 새 실수를 영영 못 배운다.
301 */
302export function learn(book: HrBook, dept: Dept, tool: string, text: string, now: number): HrBook {
303  const pattern = patternOf(text)
304  if (pattern === '') return book
305  const list = book.lessons[dept] ?? []
306  const had = list.find(l => l.tool === tool && l.pattern === pattern)
307  const next: Lesson[] = had === undefined
308    ? [...list, { tool, pattern, count: 1, lastAt: now }]
309    : list.map(l => (l === had ? { ...l, count: l.count + 1, lastAt: now } : l))
310  const kept = [...next].sort((a, b) => b.lastAt - a.lastAt).slice(0, MAX_LESSONS)
311  kept.sort((a, b) => b.count - a.count || b.lastAt - a.lastAt)
312  return { ...book, lessons: { ...book.lessons, [dept]: kept } }
313}
314
315/** 교훈의 열쇠(같은 도구·같은 문구) */
316export const lessonKey = (l: Pick<Lesson, 'tool' | 'pattern'>): string => `${l.tool}|${l.pattern}`
317
318/** 효과 없다고 판정된 실수를 메모에서 빼 두는 기간. 지나면 처음부터 다시 잰다 */
319export const RETEST_MS = 14 * 24 * 60 * 60_000
320
321/** 효과 측정 중이고, 효과 없다고 판정된 지 얼마 안 돼 메모에서 빼 둔 실수인가 */
322export function isMuted(book: HrBook, l: Pick<Lesson, 'tool' | 'pattern'>, now: number): boolean {
323  const at = book.trials[lessonKey(l)]?.ineffectiveAt
324  return book.isTrialing && at !== undefined && now - at < RETEST_MS
325}
326
327/**
328 * 메모에 붙일 부서의 실수: 2회 이상, 최근에 난 것부터 3개. now를 주면 2주 넘게 안 난 것과,
329 * 효과 측정에서 효과 없다고 나와 빼 둔 것은 뺀다.
330 */
331export const lessonsFor = (book: HrBook, dept: Dept, now?: number): Lesson[] =>
332  (book.lessons[dept] ?? [])
333    .filter(l => l.count >= 2 && (now === undefined || (now - l.lastAt <= LESSON_TTL_MS && !isMuted(book, l, now))))
334    .sort((a, b) => b.lastAt - a.lastAt)
335    .slice(0, 3)
336
337/**
338 * 업무 지시 끝에 붙일 인사 메모(짧게). 코칭이 꺼져 있으면 null.
339 * lessons를 주면 그 실수를 붙이고(비교군이면 빈 목록), 안 주면 lessonsFor로 고른다.
340 */
341export function briefOf(book: HrBook, e: Employee, now?: number, lessons: readonly Lesson[] = lessonsFor(book, e.dept, now), why: string | null = null): string | null {
342  if (!book.isCoaching) return null
343  const dept = DEPT_INFO[e.dept]
344  const lead = leadOf(book, e.dept)
345  const rank = rankOf(e.xp).ko
346  const lines = [
347    '[사내 인사 메모 · agent-office]',
348    `담당: ${dept.ko} ${e.nick} ${rank}${lead !== undefined && lead.nick !== e.nick ? ` (팀장 ${lead.nick} ${rankOf(lead.xp).ko})` : lead?.nick === e.nick ? ' · 팀장' : ''}.` +
349      (e.jobs > 0 ? ` 지난 기록: 완료 ${e.done} · 실패 ${e.failed} · 도구 오류 ${e.toolErrors}${e.reworks > 0 ? ` · 재작업 ${e.reworks}` : ''}${e.alarms > 0 ? ` · 근태 알림 ${e.alarms}` : ''}, 최근 평가 ${gradeOf(e.lastScore ?? 0)}.` : ' 첫 업무입니다.') +
350      (why === null ? '' : ` 배정 사유: ${why}.`),
351  ]
352  if (lessons.length > 0) {
353    lines.push(`${dept.ko}이 최근 반복한 실수입니다. 이번에는 피하세요:`)
354    for (const l of lessons) {
355      const tip = tipOf(l.pattern)
356      lines.push(`- ${l.tool} "${l.pattern}" ${l.count}회${tip === null ? '' : ` → ${tip}`}`)
357    }
358  }
359  lines.push('평가 기준: 맡은 일을 끝까지 완료할 것, 도구 오류를 줄일 것, 실패한 테스트·빌드를 두고 끝내지 말 것. 끝낼 때 한 일·확인한 것·남은 위험을 짧게 보고할 것.')
360  return lines.join('\n')
361}
362
hooks/speech.ts 171 lines
1// 사내 대화. 직원들이 일하며 하는 말을 만든다: 평면도 머리 위에 잠깐 뜨는 짧은 말풍선(영문, 평면도 글자는 영문뿐)과
2// 평면도 아래 사내 메신저에 남는 한 줄(한글). 지시·보고·메시지·혼잣말·실수·결재. 순수 함수만 둔다.
3import type { Bubble, CheckRun, Utterance, UtteranceKind } from '../types'
4import { isCheckSegment, isShell, segmentsOf } from './watch'
5
6/** 말풍선이 떠 있는 시간 */
7export const BUBBLE_MS = 3500
8/** 말풍선 글자 수 */
9export const BUBBLE_CHARS = 14
10/** 메신저에 남기는 줄 수 */
11export const FEED_MAX = 40
12const LINE_CHARS = 90
13
14const baseName = (f: string): string => f.split(/[\\/]/).pop() ?? f
15/**
16 * 화면에 보일 글을 다듬는다: 터미널 제어 문자(색·커서 이동)와 글 방향 뒤집기를 지우고,
17 * 비밀처럼 보이는 값(토큰·키·암호·Authorization)은 가린다. 메신저는 다른 mod도 읽는 세션 상태다.
18 */
19export const scrub = (s: string): string =>
20  s.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
21    .replace(/[\x00-\x08\x0b-\x1f\x7f\u202a-\u202e\u2066-\u2069]/g, '')
22    // Authorization: <방식> <값>: 방식은 두고 값을 가린다
23    .replace(/\b(authorization\s*[:=]\s*)(?:(basic|bearer|token|digest)\s+)?[^\s"']+/gi, (_m, head: string, scheme?: string) => `${head}${scheme === undefined ? '' : `${scheme} `}***`)
24    // 이름=값·이름: 값만 가린다("token 만료" 같은 평범한 말은 두고), Bearer 뒤는 값처럼 생겼을 때만
25    .replace(/\b(token|secret|password|passwd|api[_-]?key)(\S*?)(\s*[:=]\s*)(["']?)(?!\*\*\*)[^\s"']+/gi, '$1$2$3$4***')
26    .replace(/\b(bearer\s+)(?!\*\*\*)[A-Za-z0-9._~+/=-]{8,}/gi, '$1***')
27    .replace(/\b(sk|pk|ghp|gho|xox[abp])[-_][A-Za-z0-9_-]{8,}/g, '***')
28/** 한 줄로 줄여 앞부분만 */
29export const headOf = (s: string, n = LINE_CHARS): string => {
30  const one = Array.from(scrub(s).replace(/\s+/g, ' ').trim())
31  return one.length <= n ? one.join('') : `${one.slice(0, n - 1).join('')}…`
32}
33/** 평면도에 그릴 수 있는 글자만 남긴다(영문·숫자·기호). 남는 게 없으면 대신할 말 */
34export const asciiOf = (s: string, or: string, n = BUBBLE_CHARS): string => {
35  const t = scrub(s).replace(/[^\x20-\x7e]/g, '').replace(/\s+/g, ' ').trim()
36  const kept = t.length >= 2 ? t : or
37  return kept.length <= n ? kept : `${kept.slice(0, n - 1)}~`
38}
39
40const said = (at: number, kind: UtteranceKind, from: string, text: string, to?: string): Utterance =>
41  ({ at, kind, from, text: headOf(text), ...(to === undefined ? {} : { to }) })
42
43const str = (x: unknown): string => (typeof x === 'string' ? x : '')
44
45/**
46 * 도구를 부를 때 하는 혼잣말: 말풍선은 늘, 메신저 줄은 말로 된 설명이 있을 때만(Bash·PowerShell의 description).
47 * 도구마다 무엇을 하는지 짧게.
48 */
49export function selfTalkOf(nick: string, e: { tool: string } & object, at: number): { bubble: Bubble; line: Utterance | null } {
50  const x = e as Record<string, unknown>
51  const file = baseName(str(x.file_path) || str(x.notebook_path) || str(x.path))
52  const bub = (s: string): Bubble => ({ text: asciiOf(s, '...'), at, tone: 'self' })
53  const desc = str(x.description)
54  let bubble: Bubble
55  switch (e.tool) {
56    // 명령은 앞 낱말만(인자에 비밀이 섞일 수 있다): 환경 변수 대입은 건너뛴다
57    case 'Bash': case 'PowerShell': bubble = bub(`$ ${str(x.command).trim().split(/\s+/).find(w => !w.includes('=')) ?? ''}`); break
58    case 'Edit': case 'Write': case 'NotebookEdit': bubble = bub(`edit ${file}`); break
59    case 'Read': bubble = bub(`read ${file}`); break
60    case 'Grep': bubble = bub(`grep ${str(x.pattern)}`); break
61    case 'Glob': bubble = bub(`find ${str(x.pattern)}`); break
62    case 'WebSearch': bubble = bub(`web ${str(x.query)}`); break
63    case 'WebFetch': bubble = bub(`web ${(str(x.url).match(/^\w+:\/\/([^/?#]+)/) ?? [])[1] ?? ''}`); break
64    case 'TodoWrite': case 'TaskCreate': case 'TaskUpdate': bubble = bub('todo...'); break
65    default: bubble = bub(e.tool.startsWith('mcp__') ? (e.tool.split('__')[2] ?? 'mcp') : e.tool.toLowerCase())
66  }
67  // 말로 된 설명이 있는 도구만 메신저에 남긴다(모든 도구를 남기면 소음이다)
68  const line = isShell(e.tool) && desc.trim() !== '' ? said(at, 'self', nick, desc) : null
69  return { bubble, line }
70}
71
72/** 근거로 남길 한 줄: 공백·줄바꿈을 한 칸으로 접고 백틱은 '로 바꿔(칸을 깨거나 가짜 줄을 만들지 못하도록) 비밀을 가린다 */
73const oneLine = (s: string, max: number): string => scrub(s.replace(/\s+/g, ' ').replace(/`/g, "'").trim()).slice(0, max)
74
75/** 출력의 첫(또는 마지막) 의미 있는 줄: "Exit code N" 줄은 건너뛰고, 한 줄로, 비밀은 가리고 */
76export function lineOf(text: string, which: 'first' | 'last', max = 120): string {
77  const lines = text.split('\n').map(l => l.trim()).filter(l => l !== '' && !/^exit code \d+$/i.test(l))
78  return oneLine((which === 'first' ? lines[0] : lines[lines.length - 1]) ?? '', max)
79}
80
81/**
82 * 명령 이름: 첫 줄을 &&·;로 나눠 확인(테스트·빌드)으로 보이는 조각(없으면 마지막 조각)에서, 환경 변수 대입을 건너뛰고
83 * 앞 낱말을 세 개까지, 옵션·인자·특수 문자를 만나면 멈춘다(cd x && npm test → npm test, ./gradlew build → ./gradlew build).
84 * 인자에 섞일 수 있는 비밀은 남기지 않고, 남은 것도 가린다
85 */
86export function commandNameOf(command: string): string {
87  const segs = segmentsOf(command)
88  const seg = segs.find(isCheckSegment) ?? segs[segs.length - 1] ?? ''
89  const out: string[] = []
90  for (const w of seg.split(/\s+/)) {
91    if (out.length === 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) continue
92    if (!/^(\.{0,2}\/)?[A-Za-z0-9][\w.:/@+-]*$/.test(w) || w.includes('://') || out.length >= 3) break
93    out.push(w)
94  }
95  const name = scrub(out.join(' ')).slice(0, 60)
96  return name === '' ? '(명령)' : name
97}
98
99/** 테스트·빌드 명령 한 번의 기록(결재 근거). 종료 코드를 믿을 수 없는 명령은 "알 수 없음" */
100export function checkRunOf(e: object, isFailed: boolean, text: string): CheckRun {
101  const x = e as Record<string, unknown>
102  const command = str(x.command)
103  const isUnsure = x.run_in_background === true || /\|\||[^|]\|[^|]|;|\n|&\s*$/.test(command.trim())
104  return { cmd: commandNameOf(command), result: isUnsure ? 'unsure' : isFailed ? 'fail' : 'pass', tail: lineOf(text, 'last', 80) }
105}
106
107/** 도구 오류 한 줄(결재 근거) */
108export const errLineOf = (tool: string, text: string): string => `${tool}: ${lineOf(text, 'first')}`
109
110/** 도구가 실패했을 때: 말풍선 oops!, 메신저에 무엇이 실패했는지 */
111export function oopsOf(nick: string, tool: string, text: string, at: number): { bubble: Bubble; line: Utterance } {
112  const why = text.split('\n').map(l => l.trim()).find(l => l !== '' && !/^exit code \d+$/i.test(l)) ?? ''
113  return {
114    bubble: { text: 'oops!', at, tone: 'bad' },
115    line: said(at, 'error', nick, `앗, ${tool} 실패${why === '' ? '' : ` — ${why}`}`),
116  }
117}
118
119/** 업무 지시: 띄운 쪽이 직원을 불러 맡긴다 */
120export function orderOf(from: string, to: string, description: string, prompt: string, at: number, why: string | null = null): { bubble: Bubble; line: Utterance } {
121  const body = prompt.split('\n\n[사내 인사 메모')[0] ?? prompt
122  return {
123    bubble: { text: asciiOf(`${to}, go!`, 'go!'), at, tone: 'talk' },
124    line: said(at, 'order', from, `${description}${why === null ? '' : ` (배정: ${why})`}${body.trim() === '' ? '' : ` — ${body}`}`, to),
125  }
126}
127
128/** 받는 쪽의 대답(지시받을 때 "넵!", 보고받을 때 "수고!") */
129export const replyOf = (text: string, at: number): Bubble => ({ text, at, tone: 'talk' })
130
131/** 끝낸 일의 보고 */
132export function reportLineOf(from: string, to: string, answer: string, at: number): { bubble: Bubble; line: Utterance } {
133  return {
134    bubble: { text: 'done!', at, tone: 'good' },
135    line: said(at, 'report', from, answer.trim() === '' ? '(보고 없이 끝냄)' : answer, to),
136  }
137}
138
139/** SendMessage: 직원끼리 주고받는 메시지 */
140export function messageOf(from: string, to: string, e: object, at: number): { bubble: Bubble; line: Utterance } {
141  const x = e as Record<string, unknown>
142  const body = str(x.message) || str(x.summary) || str(x.content)
143  return {
144    bubble: { text: asciiOf(`-> ${to}`, '->'), at, tone: 'talk' },
145    line: said(at, 'message', from, body === '' ? '(메시지)' : body, to),
146  }
147}
148
149/** 인사 발령(조직 개편 등): 인사팀이 알린다 */
150export const hrLineOf = (text: string, at: number): Utterance => said(at, 'hr', '인사팀', text)
151
152/** 결재·계획 검토 결과(결재자는 평면도에 없을 수 있어 메신저에만) */
153export const approvalLineOf = (from: string, text: string, at: number): Utterance => said(at, 'approval', from, text)
154
155/** 메신저에 한 줄 더한다(최근 것이 뒤, FEED_MAX까지) */
156export const pushLine = (feed: readonly Utterance[], u: Utterance): Utterance[] => [...feed, u].slice(-FEED_MAX)
157
158/** 말풍선이 아직 떠 있는가 */
159export const isSpeaking = (b: Bubble | undefined, now: number): b is Bubble => b !== undefined && now - b.at < BUBBLE_MS
160
161const KIND_KO: Record<UtteranceKind, string> = { order: '지시', report: '보고', message: '메시지', self: '혼잣말', error: '', approval: '결재', hr: '발령' }
162
163/** 메신저 한 줄: "12초 전 BOSS → BEAR (지시): …" */
164export function lineText(u: Utterance, now: number): string {
165  const s = Math.max(0, Math.floor((now - u.at) / 1000))
166  const ago = s < 60 ? `${s}초 전` : s < 3600 ? `${Math.floor(s / 60)}분 전` : `${Math.floor(s / 3600)}시간 전`
167  const who = u.to === undefined ? u.from : `${u.from} → ${u.to}`
168  const kind = KIND_KO[u.kind]
169  return `${ago} ${who}${kind === '' ? '' : ` (${kind})`}: ${u.text}`
170}
171
hooks/trial.ts 77 lines
1// 교육 효과 측정. 인사 메모에 붙이는 "반복한 실수"가 정말 재발을 줄이는지 본다.
2// 붙일 실수가 있는 업무 중 몇 건에 한 건은 실수 목록을 빼고 보내(비교군), 일하는 동안 그 실수가 다시 났는지를
3// 실수마다 메모군·비교군으로 나눠 센다. 근거가 충분히 모였는데 메모를 붙여도 재발이 줄지 않으면 효과 없음으로 보고
4// 한동안 메모에서 뺐다가, 시간이 지나면 처음부터 다시 잰다. 순수 함수만 둔다.
5import type { HrBook, TrialArm, TrialStats, TrialTicket } from '../types'
6import { patternOf, RETEST_MS } from './hr'
7
8/** 실험에 넣을 업무 몇 건에 한 건을 비교군으로 둔다 */
9export const CONTROL_EVERY = 5
10/** 판정에 필요한 최소 표본: 메모군·비교군 업무 수 */
11export const MIN_MEMO = 10
12export const MIN_CONTROL = 5
13/** 비교군에서 이만큼은 재발해야 판정한다(재발이 드물면 비교할 거리가 없다) */
14export const MIN_CONTROL_HITS = 2
15/** 실험 기록은 최근 것 50개까지 */
16const MAX_TRIALS = 50
17
18/** 도구 오류 하나의 열쇠: 교훈과 같은 방식으로 다듬는다 */
19export const errorLessonKey = (tool: string, text: string): string => `${tool}|${patternOf(text)}`
20
21/**
22 * 이번 업무를 어느 쪽에 넣을지 정한다. 붙일 실수가 없거나 실험이 꺼져 있으면 실험하지 않는다(null).
23 * 순번으로 고른다: 무작위보다 고르고, 같은 기록이면 늘 같은 결과가 나온다.
24 */
25export function enroll(book: HrBook, keys: readonly string[]): { book: HrBook; arm: TrialArm } | null {
26  if (!book.isTrialing || keys.length === 0) return null
27  const seq = book.trialSeq + 1
28  return { book: { ...book, trialSeq: seq }, arm: seq % CONTROL_EVERY === 0 ? 'control' : 'memo' }
29}
30
31/** 일하는 동안 난 오류가 실험 중인 실수면 재발로 적는다 */
32export const markHit = (ticket: TrialTicket, key: string): TrialTicket =>
33  ticket.keys.includes(key) && !ticket.hits.includes(key) ? { ...ticket, hits: [...ticket.hits, key] } : ticket
34
35const rate = (s: { jobs: number; hits: number }): number => (s.jobs === 0 ? 0 : s.hits / s.jobs)
36
37export type Verdict = 'effective' | 'ineffective' | 'unknown'
38
39/**
40 * 효과 판정. 메모군·비교군 표본이 모이고 비교군에서 재발이 충분히 나야 가른다.
41 * 그 전이나 둘 다 재발이 없을 때는 모른다(드문 실수를 근거 없이 빼지 않도록).
42 */
43export function verdictOf(s: TrialStats | undefined): Verdict {
44  if (s === undefined || s.memo.jobs < MIN_MEMO || s.control.jobs < MIN_CONTROL || s.control.hits < MIN_CONTROL_HITS) return 'unknown'
45  return rate(s.memo) < rate(s.control) ? 'effective' : 'ineffective'
46}
47
48const fresh = (now: number): TrialStats => ({ memo: { jobs: 0, hits: 0 }, control: { jobs: 0, hits: 0 }, lastAt: now })
49
50/**
51 * 끝난 업무 하나를 실수별 실험 기록에 더한다. 효과 없음으로 판정되면 그때를 적어 두고(한동안 메모에서 빠진다),
52 * 다시 잴 때가 지난 기록은 처음부터 다시 센다.
53 */
54export function settle(book: HrBook, ticket: TrialTicket, now: number): HrBook {
55  const trials = { ...book.trials }
56  for (const key of ticket.keys) {
57    const had = trials[key]
58    const base = had === undefined || (had.ineffectiveAt !== undefined && now - had.ineffectiveAt >= RETEST_MS) ? fresh(now) : had
59    const arm = base[ticket.arm]
60    const next: TrialStats = { ...base, [ticket.arm]: { jobs: arm.jobs + 1, hits: arm.hits + (ticket.hits.includes(key) ? 1 : 0) }, lastAt: now }
61    const { ineffectiveAt: _drop, ...rest } = next
62    trials[key] = verdictOf(next) === 'ineffective' ? { ...rest, ineffectiveAt: base.ineffectiveAt ?? now } : rest
63  }
64  const kept = Object.entries(trials).sort(([, a], [, b]) => b.lastAt - a.lastAt).slice(0, MAX_TRIALS)
65  return { ...book, trials: Object.fromEntries(kept) }
66}
67
68const pct = (s: { jobs: number; hits: number }): string => `${s.jobs}건 중 재발 ${s.hits}(${Math.round(rate(s) * 100)}%)`
69
70/** /office hr에 보일 효과 한 줄(실험한 적 없으면 빈 문자열) */
71export function effectText(s: TrialStats | undefined, isMuted: boolean): string {
72  if (s === undefined) return ''
73  const v = verdictOf(s)
74  const word = v === 'effective' ? '효과 있음' : v === 'ineffective' ? (isMuted ? '효과 없음(2주 동안 메모에서 뺀 뒤 다시 잼)' : '효과 없음') : '측정 중'
75  return ` · ${word}: 메모 ${pct(s.memo)} / 비교군 ${pct(s.control)}`
76}
77
hooks/scene.ts 717 lines
1// 위에서 내려다본 게임 속 사무실. 위층에 사장실·회의실·자료실·서버실·화장실, 가운데 복도(왼쪽 끝 입구와 안내 데스크),
2// 아래층에 부서별 책상 섬이 있는 사무 공간과 탕비실(조리대·식탁·소파)이 있다.
3// 일하는 모습은 실제 그대로 옮긴다: 작성·웹·생각 → 자기 부서 책상, 읽기 → 자료실, 터미널 → 서버실, 위임 → 회의실.
4// 대화(지시·SendMessage·보고)는 회의실에 모여서 한다. 기다리는 동안(대기)만 탕비실·소파·화장실을 오간다. 완료하면 보고 뒤 퇴근.
5// 셀 하나에 '▀'(위 픽셀=글자색, 아래 픽셀=배경색)로 픽셀 두 개를 그리고, 표지·명패·이름표만 ASCII 글자 셀로 덮는다.
6import type { Dept, OfficeActivity, OfficeAgent } from '../types'
7import { DEPT_INFO, DEPTS } from './hr'
8import { isSpeaking } from './speech'
9
10/** 평면도 높이(셀 줄): 제목 1줄 + 건물 26줄 */
11export const PLAN_ROWS = 27
12const MIN_W = 72
13const MAX_W = 140
14
15const HALF = 0x2580
16const C = {
17  wall: 0x3d405b, wallTop: 0x2f3247, window: 0x9fd3ff, frame: 0xdfe6ee, corridor: 0x8a7a66, corridor2: 0x7f7060,
18  runner: 0x8c3b47, desk: 0x8b5a2b, deskLead: 0x6d4224, mon: 0x222831, skin: 0xf1c27d, plant: 0x2e7d32, leaf: 0x66bb6a,
19  pot: 0xa0522d, sofa: 0x9c4a4a, wood: 0x6b4220, table: 0xb08968, white: 0xf5f5f5, porcelain: 0xe8eef2, steel: 0x9e9e9e,
20  water: 0x4fc3f7, rack: 0x2b2b33, label: 0x1b1c28, text: 0xd8d8e0, dim: 0x8a8aa0, green: 0x4caf50, red: 0xe53935,
21  yellow: 0xffd54f, black: 0x0d0d0d, pants: 0x2f3e66, mat: 0x5d4037, tv: 0x1a237e, lamp: 0xfff59d,
22}
23const FLOOR: Record<RoomId, [number, number]> = {
24  ceo: [0x6b4f3a, 0x634935], meet: [0x50606b, 0x4a5963], library: [0x5c5240, 0x564c3b], server: [0x3a3f48, 0x353a42],
25  wc: [0xcfd8dc, 0xb0bec5], office: [0x4b5068, 0x474b62], pantry: [0x8d7b68, 0x857360], corridor: [C.corridor, C.corridor2],
26}
27const HAIRS = [0x3b2416, 0x111111, 0xa0522d, 0xdaa520, 0x6b4f3a]
28const BOOKS = [0xe76f51, 0x2a9d8f, 0xe9c46a, 0x8ecae6, 0xb5838d, 0x90be6d, 0x6d6875]
29
30/** 별칭: 이름표(ASCII)와 목록(한글)에 쓴다. color는 셔츠·이름표 색 */
31export const NICKS = [
32  { en: 'OWL', ko: '부엉이', color: 0xe0c050 },
33  { en: 'BEAR', ko: '곰', color: 0xc07a4a },
34  { en: 'HAWK', ko: '매', color: 0xe76f51 },
35  { en: 'FOX', ko: '여우', color: 0xf4a261 },
36  { en: 'CAT', ko: '고양이', color: 0xcdb4db },
37  { en: 'CRANE', ko: '두루미', color: 0x8ecae6 },
38  { en: 'OTTER', ko: '수달', color: 0x90be6d },
39  { en: 'WHALE', ko: '고래', color: 0x48cae4 },
40  { en: 'PANDA', ko: '판다', color: 0xdddddd },
41  { en: 'TIGER', ko: '호랑이', color: 0xff9f1c },
42  { en: 'KOALA', ko: '코알라', color: 0xd4a5b5 },
43  { en: 'WOLF', ko: '늑대', color: 0xaab4c0 },
44  { en: 'DEER', ko: '사슴', color: 0xd4a373 },
45  { en: 'SEAL', ko: '물범', color: 0x7da7d9 },
46  { en: 'MOLE', ko: '두더지', color: 0xb07d5a },
47  { en: 'LYNX', ko: '스라소니', color: 0xe9c46a },
48] as const
49const BOSS = { en: 'BOSS', ko: '보스', color: 0xd97757 }
50
51// 종류마다 먼저 주는 별칭(이미 쓰고 있으면 다음 빈 별칭)
52const PREFERRED: Record<string, string> = {
53  explore: 'OWL', builder: 'BEAR', executor: 'BEAR', reviewer: 'HAWK', 'code-reviewer': 'HAWK',
54  planner: 'FOX', plan: 'FOX', verifier: 'CAT', writer: 'CRANE', debugger: 'OTTER',
55  'document-specialist': 'WHALE', 'general-purpose': 'PANDA', architect: 'TIGER', critic: 'WOLF',
56}
57
58/** 보이는 이름: 'oh-my-claudecode:executor' → 'executor' */
59export const shortType = (type: string): string => type.split(':').pop() ?? type
60
61/** 새 별칭. 한 번이라도 쓴 별칭(all)은 피하고, 다 쓰면 번호를 붙인다. */
62export const nickFor = (type: string, agents: readonly Pick<OfficeAgent, 'nick' | 'endedAt'>[]): string => {
63  if (type === 'main') return BOSS.en
64  const all = new Set(agents.map(a => a.nick))
65  const want = PREFERRED[shortType(type).toLowerCase()]
66  if (want !== undefined && !all.has(want)) return want
67  const free = NICKS.find(n => !all.has(n.en))
68  if (free !== undefined) return free.en
69  for (let i = 2; ; i++) for (const n of NICKS) if (!all.has(`${n.en}${i}`)) return `${n.en}${i}`
70}
71
72const nickInfo = (nick: string) => {
73  if (nick === BOSS.en) return BOSS
74  const base = NICKS.find(n => nick.startsWith(n.en)) ?? NICKS[0]
75  return { en: nick, ko: `${base.ko}${nick.slice(base.en.length)}`, color: base.color }
76}
77
78/** 목록에 쓸 한글 별칭 */
79export const koNick = (nick: string): string => nickInfo(nick).ko
80
81const hash = (s: string): number => {
82  let h = 7
83  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0
84  return h
85}
86
87const shade = (c: number, k: number): number =>
88  (Math.min(255, Math.round(((c >> 16) & 255) * k)) << 16) | (Math.min(255, Math.round(((c >> 8) & 255) * k)) << 8) | Math.min(255, Math.round((c & 255) * k))
89
90/** 도구 이름 → 하는 일 */
91export const activityOf = (tool: string): OfficeActivity => {
92  if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(tool)) return 'type'
93  if (/^(Read|Grep|Glob|LS|ToolSearch|TaskGet|TaskList)$/.test(tool) || /lsp|ast_grep/i.test(tool)) return 'read'
94  if (/^(Bash|Monitor|PowerShell|TaskStop)$/.test(tool)) return 'bash'
95  if (/^(WebFetch|WebSearch)$/.test(tool) || tool.startsWith('mcp__')) return 'web'
96  if (/^(Agent|Task|SendMessage|Workflow|Skill)$/.test(tool)) return 'spawn'
97  return 'think'
98}
99
100/** 목록 순서: 메인, 일하는 중, 최근 끝난 순 */
101export const seated = (agents: readonly OfficeAgent[]): OfficeAgent[] => {
102  const isLive = (a: OfficeAgent) => a.endedAt === null
103  return [...agents].sort((a, b) => (a.id === 'main' ? -1 : b.id === 'main' ? 1 : isLive(a) !== isLive(b) ? (isLive(a) ? -1 : 1) : b.startedAt - a.startedAt))
104}
105
106/** 지금 회의실에서 대화 중인가 */
107export const isMeeting = (a: OfficeAgent, now: number): boolean => (a.meetUntil ?? 0) > now
108
109export type Layout = { columns: number; rows: number }
110
111/** 패널 폭에 맞춘 평면도 크기(좁으면 최소 폭으로 그려 패널이 자른다) */
112export const layoutOf = (columns: number): Layout => ({ columns: Math.min(MAX_W, Math.max(MIN_W, columns)), rows: PLAN_ROWS })
113
114// ── 평면도 ────────────────────────────────────────────────────────────
115
116export type RoomId = 'ceo' | 'meet' | 'library' | 'server' | 'wc' | 'office' | 'pantry' | 'corridor'
117
118type Room = { id: RoomId; name: string; x: number; x2: number; isTop: boolean; doorX: number }
119type Pt = { x: number; y: number }
120/** 사람이 설 자리(머리 왼쪽 위, 사람은 4×4 픽셀). up이면 위를 보고, hidden이면 칸 안이라 안 보인다 */
121type Spot = Pt & { room: RoomId; up: boolean; hidden?: boolean }
122type Cluster = { x: number; y: number; dept?: Dept }
123
124const TOP_Y = 2
125const TOP_B = 19
126const WALK_Y = 22
127const BOT_Y = 27
128const BOT_B = 53
129const TOP_LANE = 14
130const BOT_LANE = 28
131const CLUSTER_W = 20
132const CLUSTER_ROWS = [34, 46]
133
134type Plan = {
135  w: number
136  rooms: Room[]
137  bossSeat: Spot
138  clusters: Cluster[]
139  spots: Record<'meet' | 'library' | 'server' | 'wc' | 'coffee' | 'table' | 'sofa', Spot[]>
140  entrance: Spot
141  decor: { cooler: number; vending: number; plants: number[] }
142}
143
144function planOf(w: number): Plan {
145  const isNarrow = w < 90
146  const b1 = isNarrow ? 15 : 18
147  const b2 = b1 + Math.min(26, Math.max(17, Math.round(w * 0.25)))
148  const b4 = w - 1 - (isNarrow ? 13 : 16)
149  const b3 = b4 - (isNarrow ? 11 : 13)
150  const b5 = w - 1 - (isNarrow ? 24 : 28)
151  const room = (id: RoomId, name: string, x: number, x2: number, isTop: boolean, doorX?: number): Room =>
152    ({ id, name, x, x2, isTop, doorX: doorX ?? x + Math.floor((x2 - x) / 2) })
153  const rooms = [
154    room('ceo', 'CEO', 0, b1, true),
155    room('meet', 'MEETING', b1, b2, true),
156    room('library', 'LIBRARY', b2, b3, true),
157    room('server', 'SERVER', b3, b4, true),
158    room('wc', 'WC', b4, w - 1, true, b4 + 3),
159    room('office', 'OFFICE', 0, b5, false, Math.floor(b5 * 0.55)),
160    room('pantry', 'PANTRY', b5, w - 1, false, b5 + 4),
161  ]
162  const perRow = Math.max(1, Math.floor((b5 - 3) / CLUSTER_W))
163  const pad = Math.floor((b5 - 1 - perRow * CLUSTER_W) / 2) + 1
164  const clusters: Cluster[] = []
165  for (const y of CLUSTER_ROWS) for (let k = 0; k < perRow; k++) clusters.push({ x: pad + k * CLUSTER_W, y })
166  const up = (r: RoomId, x: number, y: number): Spot => ({ room: r, x, y, up: true })
167  const down = (r: RoomId, x: number, y: number): Spot => ({ room: r, x, y, up: false })
168  const stalls = Math.max(1, Math.floor((w - 1 - b4 - 2) / 4))
169  const meetXs = [5, 11, 17, 23].filter(dx => b1 + dx + 2 < b2 - 2)
170  return {
171    w,
172    rooms,
173    bossSeat: up('ceo', Math.floor(b1 / 2) - 1, 11),
174    clusters,
175    spots: {
176      // 탁자 아래(위를 봄), 위(아래를 봄)
177      meet: [...meetXs.map(dx => up('meet', b1 + dx, 12)), ...meetXs.map(dx => down('meet', b1 + dx + 1, 5))],
178      library: Array.from({ length: Math.max(1, Math.floor((b3 - b2 - 3) / 6)) }, (_, i) => up('library', b2 + 3 + i * 6, 8)),
179      server: Array.from({ length: Math.max(1, Math.floor((b4 - b3 - 2) / 4)) }, (_, i) => up('server', b3 + 2 + i * 4, 13)),
180      wc: [
181        ...Array.from({ length: stalls }, (_, i) => ({ ...up('wc', b4 + 2 + i * 4, 4), hidden: true })),
182        up('wc', b4 + 3, 13),
183      ],
184      coffee: [up('pantry', b5 + 4, 31), up('pantry', b5 + 9, 31)],
185      table: [down('pantry', b5 + 6, 33), down('pantry', b5 + 11, 33), up('pantry', b5 + 6, 40), up('pantry', b5 + 11, 40)],
186      sofa: [down('pantry', b5 + 4, 45), down('pantry', b5 + 8, 45), down('pantry', b5 + 12, 45)],
187    },
188    entrance: down('corridor', 1, WALK_Y),
189    decor: {
190      cooler: Math.floor(w * 0.42),
191      vending: b5 - 6,
192      plants: [b1 - 4, b2 + 4, Math.floor((b3 + b4) / 2)].filter(x => rooms.every(r => Math.abs(r.doorX - x) > 2)),
193    },
194  }
195}
196
197const roomAt = (plan: Plan, p: Pt): RoomId => {
198  if (p.y > TOP_B - 3 && p.y < BOT_Y) return 'corridor'
199  const isTop = p.y <= TOP_B - 3
200  const row = plan.rooms.filter(r => r.isTop === isTop)
201  return [...row].reverse().find(r => p.x >= r.x)?.id ?? row[0]?.id ?? 'corridor'
202}
203
204const roomById = (plan: Plan, id: RoomId) => plan.rooms.find(r => r.id === id)
205
206/** p에서 to까지 걷는 길(방 안 통로 → 문 → 복도 → 문 → 통로 → 자리). 벽을 뚫지 않는다. */
207function routeOf(plan: Plan, p: Pt, to: Spot): Pt[] {
208  const from = roomAt(plan, p)
209  const laneOf = (r: Room) => (r.isTop ? TOP_LANE : BOT_LANE)
210  if (from === to.room && from !== 'corridor') {
211    const r = roomById(plan, from)
212    if (r === undefined) return [to]
213    return [{ x: p.x, y: laneOf(r) }, { x: to.x, y: laneOf(r) }, to]
214  }
215  const pts: Pt[] = []
216  const a = roomById(plan, from)
217  if (a !== undefined) pts.push({ x: p.x, y: laneOf(a) }, { x: a.doorX, y: laneOf(a) }, { x: a.doorX, y: WALK_Y })
218  else pts.push({ x: p.x, y: WALK_Y })
219  const b = roomById(plan, to.room)
220  if (b !== undefined) pts.push({ x: b.doorX, y: WALK_Y }, { x: b.doorX, y: laneOf(b) }, { x: to.x, y: laneOf(b) })
221  else pts.push({ x: to.x, y: WALK_Y })
222  pts.push(to)
223  return pts
224}
225
226// ── 그리기 ────────────────────────────────────────────────────────────
227
228const ascii = (s: string): string => s.replace(/[^\x20-\x7e]/g, '?')
229
230class Canvas {
231  readonly px: Uint32Array
232  readonly text = new Map<number, { ch: number; fg: number; bg: number }>()
233  constructor(readonly w: number, readonly h: number) {
234    this.px = new Uint32Array(w * h)
235  }
236  get(x: number, y: number): number {
237    return this.px[y * this.w + x] ?? 0
238  }
239  set(x: number, y: number, c: number) {
240    if (x >= 0 && y >= 0 && x < this.w && y < this.h) this.px[y * this.w + x] = c
241  }
242  rect(x: number, y: number, w: number, h: number, c: number) {
243    for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) this.set(x + i, y + j, c)
244  }
245  /** 글자 셀. bg를 비우면 그 셀 아래 픽셀 색을 쓴다 */
246  write(cx: number, cy: number, s: string, fg: number, bg?: number) {
247    const t = ascii(s)
248    for (let i = 0; i < t.length; i++) {
249      const x = cx + i
250      if (x < 0 || x >= this.w || cy < 0 || cy >= this.h / 2) continue
251      this.text.set(cy * this.w + x, { ch: t.charCodeAt(i), fg, bg: bg ?? this.get(x, cy * 2 + 1) })
252    }
253  }
254  /** columns * rows 셀의 [글자, 글자색, 배경색] */
255  cells(): Uint32Array {
256    const cols = this.w
257    const rows = this.h / 2
258    const out = new Uint32Array(cols * rows * 3)
259    for (let cy = 0; cy < rows; cy++) {
260      for (let cx = 0; cx < cols; cx++) {
261        const o = (cy * cols + cx) * 3
262        const t = this.text.get(cy * cols + cx)
263        out[o] = t?.ch ?? HALF
264        out[o + 1] = t?.fg ?? this.px[cy * 2 * cols + cx] ?? 0
265        out[o + 2] = t?.bg ?? this.px[(cy * 2 + 1) * cols + cx] ?? 0
266      }
267    }
268    return out
269  }
270}
271
272/** 모니터 두 픽셀의 색(하는 일마다 깜빡임이 다르다) */
273const screenColors = (act: OfficeActivity, f: number): [number, number] => {
274  switch (act) {
275    case 'type': return f % 2 === 0 ? [0x82aaff, 0xc792ea] : [0xc3e88d, 0x82aaff]
276    case 'read': return [0xf0f0f0, f % 3 === 0 ? 0x9e9e9e : 0xf0f0f0]
277    case 'bash': return [0x33ff66, f % 2 === 0 ? 0x33ff66 : C.black]
278    case 'web': return f % 2 === 0 ? [0x1e88e5, 0x43a047] : [0x43a047, 0x1e88e5]
279    case 'spawn': {
280      const r = [0xff595e, 0xffca3a, 0x8ac926, 0x1982c4, 0x6a4c93]
281      return [r[f % 5] ?? C.white, r[(f + 2) % 5] ?? C.white]
282    }
283    case 'think': return [0x203a5a, f % 4 < 2 ? 0x42a5f5 : 0x203a5a]
284    case 'failed': return f % 2 === 0 ? [C.red, C.red] : [C.black, C.black]
285    default: return [0x111111, 0x111111]
286  }
287}
288
289function plant(cv: Canvas, x: number, y: number) {
290  cv.set(x, y, C.leaf)
291  cv.set(x + 1, y, C.plant)
292  cv.set(x, y + 1, C.plant)
293  cv.set(x + 1, y + 1, C.leaf)
294  cv.rect(x, y + 2, 2, 1, C.pot)
295}
296
297/** 바깥 벽의 창문(창살 사이로 하늘) */
298function windows(cv: Canvas, x: number, x2: number, y: number) {
299  for (let i = x; i <= x2; i++) cv.set(i, y, (i - x) % 5 === 4 ? C.frame : C.window)
300}
301
302function drawPlan(cv: Canvas, plan: Plan, f: number, occupied: ReadonlySet<string>, busyRooms: ReadonlySet<RoomId>) {
303  const w = plan.w
304  cv.rect(0, TOP_Y, w, cv.h - TOP_Y, C.wall)
305  // 방 바닥(사장실·자료실·탕비실은 마루, 화장실은 타일)
306  for (const r of plan.rooms) {
307    const [a, b] = FLOOR[r.id]
308    const y1 = r.isTop ? TOP_Y + 1 : BOT_Y + 1
309    const y2 = r.isTop ? TOP_B - 1 : BOT_B - 1
310    const isPlank = r.id === 'ceo' || r.id === 'library' || r.id === 'pantry'
311    for (let y = y1; y <= y2; y++) for (let x = r.x + 1; x < r.x2; x++) cv.set(x, y, isPlank ? (y % 3 === 0 ? b : a) : (x + y) % 2 === 0 ? a : b)
312    // 문(3픽셀, 문턱은 바닥색)
313    cv.rect(r.doorX - 1, r.isTop ? TOP_B : BOT_Y, 3, 1, FLOOR[r.id][0])
314  }
315  // 복도: 마루, 가운데 카펫, 왼쪽 끝 입구와 매트
316  for (let y = TOP_B + 1; y < BOT_Y; y++) for (let x = 1; x < w - 1; x++) cv.set(x, y, (x + Math.floor(y / 2)) % 4 === 0 ? C.corridor2 : C.corridor)
317  cv.rect(2, WALK_Y + 2, w - 4, 1, C.runner)
318  cv.rect(0, TOP_B + 2, 1, 5, C.mat)
319  // 안내 데스크와 화분, 정수기, 자판기, 복도 화분
320  cv.rect(2, TOP_B + 1, 6, 1, C.wood)
321  cv.set(8, TOP_B + 1, C.leaf)
322  cv.rect(plan.decor.cooler, TOP_B + 1, 2, 1, C.white)
323  cv.set(plan.decor.cooler, TOP_B + 2, C.water)
324  cv.set(plan.decor.cooler + 1, TOP_B + 2, C.water)
325  cv.rect(plan.decor.vending, BOT_Y - 2, 3, 1, 0xc62828)
326  cv.set(plan.decor.vending + 1 + (f % 2), BOT_Y - 2, C.yellow)
327  for (const x of plan.decor.plants) {
328    cv.set(x, TOP_B + 1, C.leaf)
329    cv.set(x + 1, TOP_B + 1, C.plant)
330  }
331  // 바깥 벽 창문
332  for (const r of plan.rooms) if (r.isTop && r.id !== 'wc' && r.id !== 'server') windows(cv, r.x + 3, r.x2 - 3, TOP_Y)
333  const office = roomById(plan, 'office')
334  if (office !== undefined) windows(cv, 3, office.x2 - 3, BOT_B)
335
336  const room = (id: RoomId) => roomById(plan, id)!
337  // 사장실: 책장, 큰 책상, 러그, 화분
338  const ceo = room('ceo')
339  for (let y = TOP_Y + 2; y <= TOP_Y + 8; y++) cv.set(ceo.x + 1, y, BOOKS[y % BOOKS.length] ?? C.wood)
340  cv.rect(plan.bossSeat.x - 3, plan.bossSeat.y - 3, 8, 2, C.wood)
341  cv.rect(ceo.x + 3, TOP_B - 3, ceo.x2 - ceo.x - 5, 2, C.runner)
342  plant(cv, ceo.x2 - 3, TOP_Y + 2)
343  // 회의실: 화면(회의 중이면 켜짐), 긴 탁자, 의자
344  const meet = room('meet')
345  cv.rect(meet.x + 3, TOP_Y + 2, meet.x2 - meet.x - 5, 1, busyRooms.has('meet') ? (f % 4 < 2 ? 0x3949ab : 0x5c6bc0) : C.tv)
346  cv.rect(meet.x + 4, TOP_Y + 7, meet.x2 - meet.x - 7, 3, C.table)
347  for (const s of plan.spots.meet) cv.set(s.x + (s.up ? 0 : 1), s.up ? s.y : s.y + 2, 0x546e7a)
348  plant(cv, meet.x2 - 3, TOP_B - 4)
349  // 자료실: 벽면 책장, 읽는 자리 스탠드
350  const lib = room('library')
351  for (let x = lib.x + 2; x < lib.x2 - 1; x++) {
352    cv.set(x, TOP_Y + 2, BOOKS[(x * 7) % BOOKS.length] ?? C.wood)
353    cv.set(x, TOP_Y + 3, BOOKS[(x * 5 + 2) % BOOKS.length] ?? C.wood)
354    cv.set(x, TOP_Y + 4, C.wood)
355  }
356  for (const s of plan.spots.library) cv.set(s.x + 2, s.y - 1, C.lamp)
357  plant(cv, lib.x2 - 3, TOP_B - 4)
358  // 서버실: 에어컨, 랙과 깜빡이는 불빛
359  const srv = room('server')
360  cv.rect(srv.x + 2, TOP_Y + 1, srv.x2 - srv.x - 3, 1, C.white)
361  for (let x = srv.x + 2; x < srv.x2 - 1; x += 4) {
362    cv.rect(x, TOP_Y + 3, 3, 7, C.rack)
363    for (let k = 0; k < 3; k++) cv.set(x + 1, TOP_Y + 4 + k * 2, hash(`${x}${k}${Math.floor(f / 2)}`) % 3 === 0 ? C.yellow : C.green)
364  }
365  // 화장실: 칸막이와 변기(사람이 들어가면 문에 빨간 불), 거울과 세면대
366  const wc = room('wc')
367  plan.spots.wc.filter(s => s.hidden).forEach((s, i) => {
368    const x = wc.x + 1 + i * 4
369    cv.rect(x, TOP_Y + 1, 1, 7, C.steel)
370    cv.rect(s.x, TOP_Y + 2, 2, 2, C.porcelain)
371    const isTaken = occupied.has(`wc:${s.x}:${s.y}`)
372    cv.rect(x + 1, TOP_Y + 8, 3, 1, isTaken ? 0x8d6e63 : 0xbcaaa4)
373    if (isTaken) cv.set(x + 2, TOP_Y + 8, C.red)
374  })
375  cv.rect(wc.x + 2, TOP_B - 8, wc.x2 - wc.x - 3, 1, C.window)
376  cv.rect(wc.x + 2, TOP_B - 7, wc.x2 - wc.x - 3, 1, C.porcelain)
377  cv.set(wc.x + 4, TOP_B - 7, C.water)
378  cv.set(wc.x + 8, TOP_B - 7, C.water)
379  // 탕비실: 조리대(싱크·커피 머신), 냉장고, 식탁, 소파, TV, 화분
380  const pan = room('pantry')
381  cv.rect(pan.x + 2, BOT_Y + 2, pan.x2 - pan.x - 8, 2, C.steel)
382  cv.set(pan.x + 9, BOT_Y + 2, C.water)
383  cv.rect(pan.x + 3, BOT_Y + 2, 2, 2, 0x3e2723)
384  cv.set(pan.x + 4, BOT_Y + 3, f % 4 < 2 ? C.red : 0x5d0000)
385  cv.rect(pan.x2 - 5, BOT_Y + 2, 3, 6, C.white)
386  cv.set(pan.x2 - 3, BOT_Y + 4, C.steel)
387  cv.rect(pan.x + 5, 37, 10, 2, C.table)
388  cv.rect(pan.x + 3, 49, 13, 3, C.sofa)
389  cv.rect(pan.x + 3, 48, 13, 1, shade(C.sofa, 1.2))
390  cv.rect(pan.x2 - 2, 40, 1, 6, C.tv)
391  cv.set(pan.x2 - 2, 42, f % 6 < 3 ? 0x64b5f6 : C.tv)
392  plant(cv, pan.x2 - 4, BOT_B - 4)
393
394  // 표지(벽에 붙은 방 이름)
395  for (const r of plan.rooms) cv.write(r.x + 2, Math.floor((r.isTop ? TOP_Y : BOT_Y) / 2), r.name, C.dim, C.wallTop)
396}
397
398/** 위에서 본 사람: 머리 2×2, 어깨 4×1, 다리 2×1. up이면 뒤통수(위를 봄) */
399function person(cv: Canvas, p: Pt, shirt: number, hair: number, up: boolean, step: number | null) {
400  cv.rect(p.x, p.y, 2, 1, hair)
401  cv.rect(p.x, p.y + 1, 2, 1, up ? hair : C.skin)
402  cv.rect(p.x - 1, p.y + 2, 4, 1, shirt)
403  if (step === null) cv.rect(p.x, p.y + 3, 2, 1, C.pants)
404  else cv.set(p.x + (step % 2), p.y + 3, C.pants)
405}
406
407// ── 걷는 사람들(상태) ────────────────────────────────────────────────
408
409type Walker = { x: number; y: number; route: Pt[]; goal: Spot | null; goalKey: string; isGone: boolean }
410
411const SPEED = 2
412/** 기다리는 동안 자리를 바꾸는 간격(ms) */
413const IDLE_SLOT_MS = 25_000
414
415export type Leads = Partial<Record<Dept, string>>
416
417/**
418 * 한 자리(패널)의 사무실. 프레임마다 draw를 부르면 사람들이 한 걸음씩 움직인 장면을 돌려준다.
419 * 패널 폭이 바뀌면 평면도를 새로 짓고 모두 제자리에 세운다.
420 */
421export class Office {
422  private readonly plan: Plan
423  private readonly walkers = new Map<string, Walker>()
424  /** 부서 섬 안 자리: id → 0(팀장 책상)·1·2, 자리가 없으면 -1 */
425  private readonly seats = new Map<string, number>()
426  private clusterOf = new Map<Dept, number>()
427  private isFresh = true
428
429  constructor(readonly layout: Layout) {
430    this.plan = planOf(layout.columns)
431  }
432
433  /** 지금 사무실에 있는 부서를 정해진 순서로 섬에 앉힌다(섬이 모자라면 남는 부서는 탕비실 식탁) */
434  private arrange(agents: readonly OfficeAgent[]) {
435    const present = new Set(agents.filter(a => a.id !== 'main' && a.dept !== undefined && a.activity !== 'done' && !this.walkers.get(a.id)?.isGone).map(a => a.dept!))
436    const next = new Map<Dept, number>()
437    // 이미 앉은 부서는 섬을 그대로 쓴다
438    for (const [d, i] of this.clusterOf) if (present.has(d)) next.set(d, i)
439    for (const d of DEPTS) {
440      if (!present.has(d) || next.has(d)) continue
441      const used = new Set(next.values())
442      const free = this.plan.clusters.findIndex((_, i) => !used.has(i))
443      if (free >= 0) next.set(d, free)
444    }
445    this.clusterOf = next
446    this.plan.clusters.forEach((c, i) => {
447      c.dept = [...next].find(([, k]) => k === i)?.[0]
448    })
449  }
450
451  /** 부서 섬 자리: 팀장은 팀장 책상(0), 팀원은 1·2. 섬이 없거나 꽉 차면 null */
452  private deskOf(a: OfficeAgent, agents: readonly OfficeAgent[], leads: Leads): Spot | null {
453    const d = a.dept
454    const k = d === undefined ? undefined : this.clusterOf.get(d)
455    const c = k === undefined ? undefined : this.plan.clusters[k]
456    if (d === undefined || c === undefined) return null
457    let seat = this.seats.get(a.id)
458    if (seat === undefined) {
459      const mates = agents.filter(m => m.id !== a.id && m.dept === d && m.endedAt === null)
460      const used = new Set(mates.map(m => this.seats.get(m.id)).filter((s): s is number => s !== undefined))
461      const want = leads[d] === a.nick ? [0, 1, 2] : [1, 2]
462      seat = want.find(s => !used.has(s)) ?? -1
463      this.seats.set(a.id, seat)
464    }
465    if (seat < 0) return null
466    const dx = seat === 0 ? 0 : 7 + (seat - 1) * 6
467    return { room: 'office', x: c.x + dx + 2, y: c.y + 2, up: true }
468  }
469
470  /** 하는 일 → 갈 자리. 대화 중(meetUntil)이면 무엇보다 회의실이 먼저다 */
471  private goals(agents: readonly OfficeAgent[], now: number, leads: Leads): Map<string, Spot> {
472    const plan = this.plan
473    const out = new Map<string, Spot>()
474    type Shared = keyof Plan['spots']
475    const shared: Record<Shared, OfficeAgent[]> = { meet: [], library: [], server: [], wc: [], coffee: [], table: [], sofa: [] }
476    for (const a of agents) {
477      const act = a.activity
478      if (isMeeting(a, now) && !this.walkers.get(a.id)?.isGone) {
479        shared.meet.push(a)
480        continue
481      }
482      // 기다리는 동안만 쉰다: 25초마다 소파·커피·화장실 중 하나(메인은 사장실이 기본)
483      const breakRoll = hash(`${a.id}:${Math.floor(now / IDLE_SLOT_MS)}`) % 6
484      if (a.id === 'main') {
485        if (act === 'spawn') shared.meet.push(a)
486        else if (act === 'idle' && breakRoll === 0) shared.wc.push(a)
487        else if (act === 'idle' && breakRoll === 1) shared.coffee.push(a)
488        else out.set(a.id, plan.bossSeat)
489        continue
490      }
491      if (act === 'done') {
492        out.set(a.id, { ...plan.entrance, x: 0 })
493        continue
494      }
495      if (act === 'idle') {
496        shared[breakRoll === 0 ? 'wc' : breakRoll <= 2 ? 'coffee' : 'sofa'].push(a)
497        continue
498      }
499      const room = act === 'read' ? 'library' : act === 'bash' ? 'server' : act === 'spawn' ? 'meet' : null
500      if (room !== null) {
501        shared[room].push(a)
502        continue
503      }
504      const desk = this.deskOf(a, agents, leads)
505      if (desk === null) shared.table.push(a)
506      else out.set(a.id, desk)
507    }
508    for (const k of Object.keys(shared) as Shared[]) {
509      const spots = plan.spots[k]
510      shared[k].forEach((a, i) => out.set(a.id, spots[i % spots.length] ?? plan.entrance))
511    }
512    return out
513  }
514
515  private step(agents: readonly OfficeAgent[], now: number, leads: Leads) {
516    this.arrange(agents)
517    const goals = this.goals(agents, now, leads)
518    for (const a of agents) {
519      const goal = goals.get(a.id)
520      if (goal === undefined) continue
521      const key = `${goal.room}:${goal.x}:${goal.y}`
522      let wk = this.walkers.get(a.id)
523      if (wk === undefined) {
524        // 이미 퇴근한 에이전트는 들이지 않는다
525        if (a.activity === 'done' && !isMeeting(a, now)) continue
526        // 처음 지을 때는 제자리에, 그 뒤 새로 온 사람은 입구에서 걸어 들어온다
527        const at = this.isFresh ? goal : this.plan.entrance
528        wk = { x: at.x, y: at.y, route: [], goal: null, goalKey: '', isGone: false }
529        this.walkers.set(a.id, wk)
530      }
531      if (wk.isGone) continue
532      if (wk.goalKey !== key) {
533        wk.goalKey = key
534        wk.goal = goal
535        wk.route = routeOf(this.plan, wk, goal)
536      }
537      let budget = SPEED
538      while (budget > 0 && wk.route.length > 0) {
539        const t = wk.route[0]!
540        const dx = t.x - wk.x
541        const dy = t.y - wk.y
542        if (dx === 0 && dy === 0) {
543          wk.route.shift()
544          continue
545        }
546        // 가로 먼저, 다음 세로
547        if (dx !== 0) wk.x += Math.sign(dx)
548        else wk.y += Math.sign(dy)
549        budget--
550      }
551      if (wk.route.length === 0 && a.activity === 'done' && !isMeeting(a, now)) wk.isGone = true
552    }
553    // 목록에서 빠지거나 퇴근했거나 부서 섬이 없어진 사람의 자리를 비운다
554    for (const id of [...this.seats.keys()]) {
555      const a = agents.find(x => x.id === id)
556      if (a === undefined || (a.endedAt !== null && a.activity !== 'failed') || a.dept === undefined || !this.clusterOf.has(a.dept)) this.seats.delete(id)
557    }
558    this.isFresh = false
559  }
560
561  /** 한 걸음 움직이고 그린 장면의 셀. leads는 부서마다 팀장 별칭(빈 팀장 책상에 이름을 흐리게 단다) */
562  draw(agents: readonly OfficeAgent[], frame: number, now: number, leads: Leads = {}): Uint32Array {
563    this.step(agents, now, leads)
564    const plan = this.plan
565    const cv = new Canvas(this.layout.columns, this.layout.rows * 2)
566    const here = agents.filter(a => {
567      const wk = this.walkers.get(a.id)
568      return wk !== undefined && !wk.isGone
569    })
570    const settled = (a: OfficeAgent) => {
571      const wk = this.walkers.get(a.id)
572      return wk !== undefined && wk.route.length === 0 ? wk : undefined
573    }
574    const occupied = new Set(here.map(a => settled(a)?.goalKey).filter((k): k is string => k !== undefined))
575    const talkers = here.filter(t => isMeeting(t, now) && settled(t) !== undefined)
576    drawPlan(cv, plan, frame, occupied, new Set<RoomId>(talkers.length > 0 ? ['meet'] : []))
577
578    // 말풍선 자리: 줄마다 쓴 칸(이름표·말풍선). 겹치면 한 줄 위로, 머리 위가 없으면 발밑, 다 차면 이번 프레임은 쉰다
579    const taken = new Map<number, [number, number][]>()
580    const take = (x: number, row: number, len: number) => taken.set(row, [...(taken.get(row) ?? []), [x, x + len]])
581    const place = (x: number, rows: readonly number[], len: number): number | null => {
582      for (const r of rows) {
583        // 0줄은 제목, 1줄은 방 이름
584        if (r < 2 || r >= cv.h / 2) continue
585        const used = taken.get(r) ?? []
586        if (used.some(([a, b]) => x < b + 1 && a < x + len + 1)) continue
587        taken.set(r, [...used, [x, x + len]])
588        return r
589      }
590      return null
591    }
592
593    // 부서 섬: 부서색 카펫, 표지, 팀장 책상(스탠드), 팀원 책상, 모니터와 명패
594    for (const c of plan.clusters) {
595      const dept = c.dept
596      const tint = dept === undefined ? FLOOR.office[1] : shade(DEPT_INFO[dept].color, 0.32)
597      cv.rect(c.x - 1, c.y - 4, CLUSTER_W - 1, 10, tint)
598      if (dept === undefined) {
599        plant(cv, c.x + 8, c.y)
600        continue
601      }
602      cv.write(c.x, (c.y - 4) / 2, DEPT_INFO[dept].en.slice(0, CLUSTER_W - 2), DEPT_INFO[dept].color, tint)
603      take(c.x, (c.y - 4) / 2, DEPT_INFO[dept].en.slice(0, CLUSTER_W - 2).length)
604      for (let seat = 0; seat < 3; seat++) {
605        const x = seat === 0 ? c.x : c.x + 7 + (seat - 1) * 6
606        const width = seat === 0 ? 6 : 5
607        const owner = here.find(a => a.dept === dept && this.seats.get(a.id) === seat)
608        const wk = owner === undefined ? undefined : settled(owner)
609        const isAtDesk = wk !== undefined && wk.goal?.room === 'office'
610        const [s1, s2] = isAtDesk && owner !== undefined ? screenColors(owner.activity, frame) : [C.mon, C.mon]
611        cv.set(x + 2, c.y - 1, s1)
612        cv.set(x + 3, c.y - 1, s2)
613        if (seat === 0) cv.set(x + width - 1, c.y - 1, C.lamp)
614        const nick = owner?.nick ?? (seat === 0 ? leads[dept] : undefined)
615        const deskColor = seat === 0 ? C.deskLead : C.desk
616        cv.rect(x, c.y, width, 2, deskColor)
617        if (nick !== undefined) cv.write(x, c.y / 2, nick.slice(0, width).padEnd(width), owner === undefined ? shade(nickInfo(nick).color, 0.6) : nickInfo(nick).color, deskColor)
618      }
619    }
620    // 사장 책상 모니터
621    const boss = agents.find(a => a.id === 'main')
622    const [b1, b2] = screenColors(boss?.activity ?? 'idle', frame)
623    cv.set(plan.bossSeat.x, plan.bossSeat.y - 4, b1)
624    cv.set(plan.bossSeat.x + 1, plan.bossSeat.y - 4, b2)
625
626    // 사람과 이름표
627    const speakers: { a: OfficeAgent; x: number; y: number }[] = []
628    for (const a of here) {
629      const wk = this.walkers.get(a.id)!
630      const info = nickInfo(a.nick)
631      const isMoving = wk.route.length > 0
632      if (!isMoving && wk.goal?.hidden) continue
633      const isUp = !isMoving && (wk.goal?.up ?? false)
634      person(cv, wk, info.color, HAIRS[hash(a.id) % HAIRS.length] ?? C.black, isUp, isMoving ? frame : null)
635      if (isSpeaking(a.say, now)) speakers.push({ a, x: wk.x, y: wk.y })
636      else if (!isMoving) {
637        if (isMeeting(a, now)) {
638          // 회의실 대화: 말하는 사람이 돌아가며 말풍선을 띄운다
639          if (talkers[Math.floor(frame / 4) % Math.max(1, talkers.length)]?.id === a.id) {
640            cv.rect(wk.x + 3, wk.y - 2, 3, 2, C.white)
641            cv.set(wk.x + 3, wk.y, C.white)
642            cv.set(wk.x + 4, wk.y - 1, frame % 2 === 0 ? C.dim : C.white)
643          }
644        } else if (a.activity === 'idle') {
645          if (plan.spots.coffee.some(s => s.x === wk.x && s.y === wk.y)) {
646            // 커피 잔과 김
647            cv.set(wk.x + 2, wk.y + 1, C.white)
648            if (frame % 4 < 2) cv.set(wk.x + 2, wk.y, 0xcfd8dc)
649          } else {
650            const dy = frame % 6 < 3 ? 0 : 1
651            cv.set(wk.x + 3, wk.y - 2 + dy, C.white)
652            cv.set(wk.x + 4, wk.y - 2 + dy, C.white)
653            cv.set(wk.x + 4, wk.y - 1 + dy, C.white)
654          }
655        } else if (a.activity === 'think') {
656          cv.set(wk.x + 3 + (frame % 3), wk.y, C.white)
657        } else if (a.activity === 'failed' && frame % 2 === 0) {
658          cv.set(wk.x + 3, wk.y - 1, C.red)
659          cv.set(wk.x + 3, wk.y + 1, C.red)
660        } else if (a.activity !== 'done') {
661          // 일하는 손
662          cv.set(wk.x + (frame % 2 === 0 ? -1 : 2), wk.y + 1, C.skin)
663        }
664      }
665      // 이름표: 자기 책상에 앉은 사람은 명패가 있으니 생략
666      const isAtOwnDesk = !isMoving && wk.goal?.room === 'office'
667      if (!isAtOwnDesk) {
668        const tag = a.nick
669        const tx = Math.max(0, Math.min(plan.w - tag.length, wk.x + 1 - Math.floor(tag.length / 2)))
670        cv.write(tx, Math.floor(wk.y / 2) - 1, tag, a.activity === 'failed' ? C.red : info.color, C.label)
671        take(tx, Math.floor(wk.y / 2) - 1, tag.length)
672      }
673    }
674    // 말풍선은 이름표를 다 그린 뒤에 빈자리에(이름표를 덮지 않도록). 걷는 중에도 말한다
675    for (const { a, x, y } of speakers) {
676      const bubble = a.say as NonNullable<OfficeAgent['say']>
677      const text = ` ${bubble.text} `
678      const bx = Math.max(0, Math.min(plan.w - text.length, x + 1 - Math.floor(text.length / 2)))
679      const head = Math.floor(y / 2)
680      const row = place(bx, [head - 2, head - 3, head + 2], text.length)
681      if (row === null) continue
682      const fg = bubble.tone === 'bad' ? C.red : bubble.tone === 'good' ? C.green : bubble.tone === 'self' ? 0x555566 : C.black
683      cv.write(bx, row, text, fg, C.white)
684    }
685
686    // 제목
687    const live = agents.filter(a => a.id !== 'main' && a.endedAt === null).length
688    const done = agents.filter(a => a.id !== 'main' && a.endedAt !== null).length
689    const blink = frame % 4 < 2 ? '*' : ' '
690    cv.write(0, 0, ` ${blink} AGENT OFFICE  working ${live}  meeting ${talkers.length}  done ${done}`.padEnd(plan.w), C.yellow, C.label)
691    return cv.cells()
692  }
693
694  /** 그 사람이 지금 있는 방(걷는 중이면 isMoving). 들어오지 않았거나 퇴근했으면 null */
695  whereIs(id: string): { room: RoomId; isMoving: boolean } | null {
696    const wk = this.walkers.get(id)
697    if (wk === undefined || wk.isGone) return null
698    return { room: roomAt(this.plan, wk), isMoving: wk.route.length > 0 }
699  }
700}
701
702const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
703
704/** Raster cells 인코딩: u32 리틀 엔디언 바이트의 패딩 있는 base64 */
705export function encodeCells(words: Uint32Array): string {
706  const bytes = new Uint8Array(words.buffer, words.byteOffset, words.byteLength)
707  let out = ''
708  for (let i = 0; i < bytes.length; i += 3) {
709    const a = bytes[i] ?? 0
710    const b = bytes[i + 1] ?? 0
711    const c = bytes[i + 2] ?? 0
712    const n = (a << 16) | (b << 8) | c
713    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + (i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '=') + (i + 2 < bytes.length ? B64[n & 63]! : '=')
714  }
715  return out
716}
717
hooks/commands.ts 87 lines
1// /office 명령. 결재·보고서·인사 기록·켜고 끄기를 글로 답한다. 인자가 없으면 null(패널을 연다).
2import type { EngineInterface } from 'claude-code'
3
4import { APPROVER_MODEL, REJECT_PENALTY } from './approval'
5import { cardOf, findEmployee, reportOf, rosterText } from './report'
6import type { Period } from './report'
7import { IDLE_JOBS, IDLE_MS, RETIRE_AVG, RETIRE_JOBS } from './org'
8import { CONTROL_EVERY } from './trial'
9import { agentsOf, hrOf, saveHr } from './store'
10import { budgetLines, deptNamed, dollarsOf } from './budget'
11import { usdText } from './tokens'
12import { DEPT_INFO, DEPTS } from './hr'
13
14export const COMMAND = {
15  name: 'office',
16  description: '에이전트 사무실 평면도 보기. menu: 메뉴 화면(인사 명부·보고서·예산·설정을 표로), budget [<부서> <달러>|<부서> off]: 부서별 하루 비용 한도, approval on|off: 보고·계획 결재 켜고 끄기, reorg on|off: 조직 개편(권고사직·계약 종료) 켜고 끄기, report [today|week]: 일간·주간 보고, hr: 인사 기록(부서·직급·연봉·평가), hr <별칭>: 인사카드, hr on|off: 업무 지시 인사 메모 켜고 끄기, hr ab on|off: 교육 효과 측정(비교군) 켜고 끄기',
17  argumentHint: '[menu | budget [<부서> <달러>|<부서> off] | approval on|off | reorg on|off | report [today|week] | hr [<별칭>|on|off|ab on|off]]',
18}
19
20/** 글로 답할 하위 명령이면 그 답, 패널을 열 명령이면 null */
21export async function answer($: EngineInterface, args: string): Promise<string | null> {
22  const [sub, arg, arg2] = args.trim().split(/\s+/)
23  if (sub === 'approval') {
24    if (arg !== 'on' && arg !== 'off') return `결재: ${(await hrOf($)).isApproving ? '켜짐' : '꺼짐'}. 쓰는 법: /office approval on|off`
25    await saveHr($, b => ({ ...b, isApproving: arg === 'on' }))
26    return arg === 'on'
27      ? `끝낸 일의 보고를 팀장이 결재하고(과장 이상·짧은 일은 전결, 반려 -${REJECT_PENALTY}), 확정한 계획은 기획팀장이 검토합니다. 결재마다 ${APPROVER_MODEL}를 한 번 부릅니다.`
28      : '결재를 멈춥니다. 보고와 계획은 결재 없이 넘어갑니다.'
29  }
30  if (sub === 'reorg') {
31    if (arg !== 'on' && arg !== 'off') return `조직 개편: ${(await hrOf($)).isReorganizing ? '켜짐' : '꺼짐'}. 쓰는 법: /office reorg on|off`
32    await saveHr($, b => ({ ...b, isReorganizing: arg === 'on' }))
33    return arg === 'on'
34      ? `조직 개편을 합니다: 업무 ${RETIRE_JOBS}건 이상에 최근 평균 평가가 ${RETIRE_AVG} 아래면 권고사직, 업무 ${IDLE_JOBS}건 이하로 ${IDLE_MS / (24 * 60 * 60_000)}일 넘게 일이 없으면 계약 종료. 떠난 직원의 인사카드는 남습니다.`
35      : '조직 개편을 멈춥니다. 직원을 내보내지 않습니다.'
36  }
37  if (sub === 'budget') {
38    const now = await $.clock.now()
39    if (arg === undefined || arg === '') {
40      const lines = budgetLines(await hrOf($), await agentsOf($), now)
41      return lines.length > 0 ? lines.join('\n') : '부서 예산이 없습니다. 쓰는 법: /office budget <부서> <달러> (예: /office budget 개발팀 5), 없애려면 /office budget <부서> off'
42    }
43    const dept = deptNamed(arg)
44    if (dept === null) return `${arg}라는 부서가 없습니다. 부서: ${DEPTS.map(d => DEPT_INFO[d].ko).join(', ')}`
45    if (arg2?.toLowerCase() === 'off') {
46      await saveHr($, b => {
47        const { [dept]: _gone, ...budgets } = b.budgets
48        const { [dept]: _told, ...budgetNotices } = b.budgetNotices
49        return { ...b, budgets, budgetNotices }
50      })
51      return `${DEPT_INFO[dept].ko} 예산을 없앱니다.`
52    }
53    const dollars = dollarsOf(arg2 ?? '')
54    if (dollars === null) return '쓰는 법: /office budget <부서> <달러>|off (예: /office budget 개발팀 5)'
55    // 새 한도로는 다시 알릴 수 있게 지난 알림을 지운다
56    await saveHr($, b => {
57      const { [dept]: _was, ...budgetNotices } = b.budgetNotices
58      return { ...b, budgets: { ...b.budgets, [dept]: dollars }, budgetNotices }
59    })
60    return `${DEPT_INFO[dept].ko} 하루 예산을 ${usdText(dollars)}로 정합니다. 최근 24시간 비용(모델 API 정가로 어림)이 넘으면 하루 한 번 알립니다.`
61  }
62  if (sub === 'report') {
63    const want = arg?.toLowerCase()
64    if (want !== undefined && want !== 'today' && want !== 'week') return '쓰는 법: /office report [today|week] (today: 최근 24시간, week: 최근 7일)'
65    const period: Period = want ?? 'today'
66    return reportOf(await hrOf($), period, await $.clock.now(), await agentsOf($))
67  }
68  if (sub !== 'hr') return null
69  if (arg === 'ab') {
70    if (arg2 !== 'on' && arg2 !== 'off') return '쓰는 법: /office hr ab on|off (교육 효과 측정 켜기·끄기)'
71    await saveHr($, b => ({ ...b, isTrialing: arg2 === 'on' }))
72    return arg2 === 'on'
73      ? `교육 효과를 잽니다: 실수 목록을 붙일 업무 ${CONTROL_EVERY}건에 한 건은 빼고 보내(비교군), 재발률을 비교합니다.`
74      : '교육 효과 측정을 멈춥니다. 실수 목록은 모든 업무에 붙입니다(쌓인 측정 기록은 남깁니다).'
75  }
76  if (arg === 'on' || arg === 'off') {
77    await saveHr($, b => ({ ...b, isCoaching: arg === 'on' }))
78    return arg === 'on' ? '업무 지시에 인사 메모(담당·지난 기록·부서가 반복한 실수·평가 기준)를 붙입니다.' : '업무 지시에 인사 메모를 붙이지 않습니다. 평가·연봉은 계속 기록합니다.'
79  }
80  const b = await hrOf($)
81  if (arg !== undefined && arg !== '') {
82    const who = findEmployee(b, arg)
83    return who === undefined ? `${arg}라는 직원이 없습니다. /office hr로 명부를 보세요.` : cardOf(b, who, await $.clock.now())
84  }
85  return rosterText(b, await $.clock.now())
86}
87
hooks/flow.ts 416 lines
1// 업무 흐름. 엔진이 알려 주는 에이전트 상태를 사무실로 옮기고(끝남·실패·대기), 끝낸 일을 평가해 인사 기록에 남기고,
2// 보고를 결재에 올리고, 근태(같은 실수 연발·제자리걸음·야근)와 재작업을 본다. 결정은 순수 함수 모듈에 맡기고
3// 여기서는 그 결과를 상태·인사 기록·토스트·메신저로 옮긴다.
4import type { EngineInterface } from 'claude-code'
5
6import type { OfficeActivity, OfficeAgent, Streak, TrialTicket } from '../types'
7import { CHECK_PENALTY, decide, DEPT_INFO, DEPTS, deptOf, gradeOf, handOff, hire, lessonsFor, warn, rankOf, record, review, rework, REWORK_PENALTY, salaryOf } from './hr'
8import { APPROVAL_TIMEOUT_MS, APPROVER_MODEL, decisionOf, decisionToast, planNoteOf, planRequestOf, planReviewerOf, planReviewOf, REJECT_PENALTY, rejectNoteOf, requestOf, routeOf } from './approval'
9import type { Facts } from './approval'
10import { approvalLineOf, hrLineOf, replyOf, reportLineOf } from './speech'
11import { followUp, isRejectTrusted, reinstate, reorgOf } from './org'
12import { settle } from './trial'
13import { callKeyOf, isCheckCommand, ERROR_STREAK, errorKeyOf, errorToast, isAlarm, isOvertime, isPolling, nudgeOf, REPEAT_STREAK, repeatToast, stepOf } from './watch'
14import { shortType } from './scene'
15import { addTokens, chargeReview, usageOf } from './tokens'
16import { noticed, overrunsOf, overrunText } from './budget'
17import type { Overrun } from './budget'
18import { admit, agentsOf, fresh, hold, hrOf, keptNow, MEET_MS, meet, nickOfId, patch, release, save, saveHr, speak } from './store'
19
20/** 파일을 고치는 도구(재작업 판정에 쓴다) */
21export const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
22export const fileOf = (e: object): string | null => {
23  const { file_path, notebook_path } = e as { file_path?: unknown; notebook_path?: unknown }
24  const f = file_path ?? notebook_path
25  return typeof f === 'string' && f !== '' ? f : null
26}
27const baseName = (f: string): string => f.split(/[\\/]/).pop() ?? f
28export { isCheckCommand }
29
30/** 결재에 쓸 지시·보고(에이전트 id →). 패널이 구독하는 상태에 두지 않는다: 길어서 쓸 때마다 패널로 실려 간다 */
31export const papers = new Map<string, { task?: string; report?: string }>()
32/** 진행 중인 결재(에이전트 id → 결재·의견 남기기·조직 개편이 끝나면 풀린다) */
33export const approvals = new Map<string, Promise<void>>()
34/** 띄운 쪽이 아직 못 읽은 결재 결과(메인은 다음 도구 결과 뒤에 읽는다) */
35let notes: { agentId: string; parentId: string; text: string }[] = []
36const MAX_NOTES = 20
37
38export const movedText = (r: { promotedTo: string | null; demotedTo: string | null }): string =>
39  r.promotedTo !== null ? ` · ${r.promotedTo} 승진!` : r.demotedTo !== null ? ` · ${r.demotedTo}(으)로 강등` : ''
40
41/**
42 * 끝난 일을 평가해 인사 기록에 반영하고 알린다. 부르는 쪽이 그 직원을 붙잡아(hold) 두고 부른다:
43 * 평가·결재가 다 끝나면 놓고(release) 조직 개편을 본다(결재 반려까지 든 평균으로, 덜 끝난 사람을 내보내지 않도록)
44 */
45async function appraise($: EngineInterface, a: OfficeAgent, isDone: boolean, isKilled: boolean) {
46  /** 결재 쪽에 넘겼으면 그쪽이 다 끝난 뒤에 놓는다 */
47  let isPassed = false
48  try {
49    isPassed = await review1($, a, isDone, isKilled)
50  } finally {
51    if (!isPassed) release(a.nick)
52  }
53  if (!isPassed) await reorganize($)
54}
55
56/** 평가 한 건. 결재에 올렸으면 true(그 결재가 끝나면 직원을 놓고 조직 개편을 본다) */
57async function review1($: EngineInterface, a: OfficeAgent, isDone: boolean, isKilled: boolean): Promise<boolean> {
58  if (a.id === 'main') return false
59  // 교육 효과 측정: 이번 업무가 든 쪽과 재발한 실수를 교훈별로 더한다(지금 사본에서 읽는다: 동기화 뒤에 적힌 재발도 들도록)
60  // 집계한 업무에서는 실험 기록을 지운다(같은 업무를 두 번 세지 않도록)
61  // 평가에 쓰는 칸도 이 사본에서 읽는다(동기화가 읽은 뒤에 난 오류·검증 실패·근태 알림도 들도록)
62  let ticket: TrialTicket | undefined
63  let cur: OfficeAgent = a
64  await save($, patch(a.id, x => {
65    ticket = x.trial
66    cur = x
67    return x.trial === undefined ? x : { ...x, trial: undefined }
68  }))
69  const got = cur as OfficeAgent
70  const settled = ticket as TrialTicket | undefined
71  if (settled !== undefined) {
72    const at = await $.clock.now()
73    await saveHr($, x => settle(x, settled, at))
74  }
75  // 명부에 없으면 채용한다. 떠난 직원이면 새로 뽑지 않고 복직시킨다(같은 별칭이 두 사람이 되지 않도록)
76  await saveHr($, x => x.employees.some(e => e.nick === a.nick) ? x
77    : x.retired.some(e => e.nick === a.nick) ? reinstate(x, a.nick)
78    : hire(x, a.nick, a.dept ?? deptOf(a.type), a.type, a.startedAt).book)
79  const now = await $.clock.now()
80  let result: ReturnType<typeof review> | null = null
81  let xpBefore = 0
82  await saveHr($, x => {
83    xpBefore = x.employees.find(y => y.nick === a.nick)?.xp ?? 0
84    const reviewed = review(x, a.nick, { isDone, isKilled, errors: got.errors ?? 0, tools: got.tools, isCheckFailed: got.isCheckFailed }, now)
85    // 이번 일에 쓴 토큰을 직원 누적에 더한다
86    result = got.tokens === undefined ? reviewed
87      : { ...reviewed, book: { ...reviewed.book, employees: reviewed.book.employees.map(y => (y.nick === a.nick ? { ...y, tokens: addTokens(y.tokens, got.tokens), tokenJobs: (y.tokenJobs ?? 0) + 1 } : y)) } }
88    // 끝낸 일의 파일은 남겨 두고, BOSS가 곧 다시 고치면 재작업으로 본다
89    const employee = result.book.employees.find(x => x.nick === a.nick)
90    const job = employee?.jobs ?? 0
91    const logged = record(result.book, {
92      nick: a.nick, dept: a.dept ?? deptOf(a.type), job, description: got.description, startedAt: got.startedAt, endedAt: got.endedAt ?? now,
93      outcome: isDone ? 'done' : isKilled ? 'killed' : 'failed', score: result.score, errors: got.errors ?? 0,
94      isCheckFailed: isDone && got.isCheckFailed === true, isReworked: false, alarms: got.alarms ?? 0, files: (got.files ?? []).length,
95      ...(employee === undefined ? {} : { salary: salaryOf(result.book, employee) }), ...(got.tokens === undefined ? {} : { tokens: got.tokens }),
96    })
97    // 인계 시각은 기록의 끝난 시각과 같게(후속 수정·재작업을 같은 창으로 잰다)
98    return isDone ? handOff(logged, { agentId: a.id, nick: a.nick, files: got.files ?? [], endedAt: got.endedAt ?? now, job }) : logged
99  })
100  const r = result as ReturnType<typeof review> | null
101  if (r === null) return false
102  const e = r.book.employees.find(x => x.nick === a.nick)
103  // 끝낸 일의 보고를 결재에 올린다(기다리지 않는다: 결재자가 답하는 사이에도 사무실은 돈다)
104  let isPassed = false
105  if (!(isDone && e !== undefined && r.book.isApproving)) papers.delete(a.id)
106  else {
107    isPassed = true
108    const facts: Facts = {
109      description: got.description, task: papers.get(a.id)?.task ?? '', report: papers.get(a.id)?.report ?? '', tools: got.tools, errors: got.errors ?? 0,
110      isCheckFailed: got.isCheckFailed === true, alarms: got.alarms ?? 0, files: (got.files ?? []).length, durationMs: now - got.startedAt,
111      fileNames: got.files ?? [], checks: got.checks ?? [], errLines: got.errLines ?? [],
112    }
113    const parentId = got.parentId ?? 'main'
114    // 전결은 이 일을 하기 전 직급으로 가린다(이 일로 오른 직급으로 자기 일을 전결하지 않도록)
115    // 결재 → 의견 남기기 → 직원 놓기 → 조직 개편을 한 줄로 잇는다(기다리는 쪽이 이 모두가 끝난 뒤를 본다)
116    const settled = approve($, a.nick, e.jobs, xpBefore, facts).catch(() => null)
117      .then(text => {
118        if (text !== null) notes = [...notes, { agentId: a.id, parentId, text }].slice(-MAX_NOTES)
119      })
120      .finally(() => release(a.nick))
121      .then(() => reorganize($))
122      // 결재에 쓴 비용도 그 부서 예산에 든다
123      .then(() => checkBudget($))
124      .catch(() => undefined)
125      .finally(() => {
126        approvals.delete(a.id)
127        papers.delete(a.id)
128      })
129    approvals.set(a.id, settled)
130  }
131  const rank = e === undefined ? '' : ` ${rankOf(e.xp).ko}`
132  const why = isDone && got.isCheckFailed === true ? ` · 테스트·빌드 실패한 채 완료 -${CHECK_PENALTY}` : ''
133  $.ui.toast(`${a.nick}${rank} 평가 ${gradeOf(r.score)} (${r.score >= 0 ? '+' : ''}${r.score})${why}${movedText(r)}`)
134  return isPassed
135}
136
137/**
138 * 조직 개편: 계속 성과가 나쁘거나 오래 일이 없는 사람을 내보내고 알린다. 일하는 중이거나 붙잡아 둔 사람
139 * (배정됐는데 아직 안 들어옴, 평가·결재가 덜 끝남)은 건드리지 않는다. 처음 내보낼 때는 한 번 안내한다
140 */
141export async function reorganize($: EngineInterface) {
142  if (!(await hrOf($)).isReorganizing) return
143  await agentsOf($)
144  const now = await $.clock.now()
145  let gone: ReturnType<typeof reorgOf>['gone'] = []
146  let isFirst = false
147  await saveHr($, b => {
148    // 일하는 사람·붙잡아 둔 사람은 저장하는 바로 그때 본다(기다리는 사이 들어오거나 붙잡힌 사람도 들도록)
149    const r = reorgOf(b, keptNow(), now)
150    gone = r.gone
151    isFirst = gone.length > 0 && !b.isReorgNoticed
152    return isFirst ? { ...r.book, isReorgNoticed: true } : r.book
153  })
154  if (isFirst) $.ui.toast('조직 개편이 켜져 있습니다: 계속 평가가 나쁜 직원은 권고사직, 오래 일이 없는 직원은 계약 종료합니다. 떠난 직원의 인사카드는 남습니다. 끄려면 /office reorg off')
155  for (const e of gone) {
156    $.ui.toast(`인사 발령: ${DEPT_INFO[e.dept].ko} ${e.nick} ${rankOf(e.xp).ko} 퇴사 · ${e.reason}`)
157    await speak($, null, null, hrLineOf(`${DEPT_INFO[e.dept].ko} ${e.nick} ${rankOf(e.xp).ko} 퇴사 — ${e.reason}`, now))
158  }
159}
160
161/** 결재: 결재 경로를 정하고, 결재자에게 묻고, 결과를 인사 기록에 적는다. 반려면 띄운 쪽이 읽을 글을 돌려준다 */
162async function approve($: EngineInterface, nick: string, job: number, xpBefore: number, f: Facts): Promise<string | null> {
163  const b = await hrOf($)
164  const found = b.employees.find(x => x.nick === nick)
165  if (found === undefined) return null
166  const e = { ...found, xp: xpBefore }
167  const route = routeOf(b, e, f.tools)
168  if (route.kind === 'exempt') {
169    await saveHr($, x => decide(x, nick, job, 'exempt', 0).book)
170    $.ui.toast(decisionToast(e, route, f.description, null))
171    return null
172  }
173  // 보고가 없으면 결재할 거리가 없다(보고가 늦게 적힌 경우를 근거 없이 반려하지 않도록): 보류
174  if (f.report.trim() === '') {
175    $.ui.toast(decisionToast(e, route, f.description, null))
176    return null
177  }
178  if (!b.isApprovalNoticed) {
179    await saveHr($, x => ({ ...x, isApprovalNoticed: true }))
180    $.ui.toast(`결재가 켜져 있습니다: 끝낸 일의 보고마다 ${APPROVER_MODEL}를 한 번 부릅니다. 끄려면 /office approval off`)
181  }
182  // 반려가 자주 빗나간 결재자의 반려는 감점하지 않는다(의견은 그대로 전한다)
183  const penalty = isRejectTrusted(b, route.approver, await $.clock.now()) ? REJECT_PENALTY : 0
184  const ask = requestOf(e, route, f)
185  const reply = await $.model.complete({ model: APPROVER_MODEL, system: ask.system, prompt: ask.prompt, maxTokens: 400, effort: 'low', timeoutMs: APPROVAL_TIMEOUT_MS })
186  // 결재에 쓴 토큰은 답을 못 받았어도 그 일 기록에 남긴다
187  const spent = usageOf(reply.usage ?? {}, APPROVER_MODEL)
188  const d = reply.isAnswered ? decisionOf(reply.text) : null
189  if (d === null) {
190    await saveHr($, x => chargeReview(x, nick, job, spent))
191    $.ui.toast(decisionToast(e, route, f.description, null))
192    return null
193  }
194  let moved: ReturnType<typeof decide> | null = null
195  await saveHr($, x => {
196    moved = decide(chargeReview(x, nick, job, spent), nick, job, d.verdict, penalty, route.approver)
197    return moved.book
198  })
199  const m = moved as ReturnType<typeof decide> | null
200  $.ui.toast(`${decisionToast(e, route, f.description, d, penalty)}${m === null ? '' : movedText(m)}`)
201  await speak($, null, null, approvalLineOf(route.approverTitle,
202    `${e.nick} "${f.description}" ${d.verdict === 'approved' ? '승인' : `반려${d.notes[0] === undefined ? '' : ` — ${d.notes[0]}`}`}`, await $.clock.now()))
203  return d.verdict === 'rejected' ? rejectNoteOf(e, route, f.description, d) : null
204}
205
206/** 메인이 계획을 확정했다: 기획팀장이 검토하고, 보완이 필요하면 메인이 읽을 글을 돌려준다 */
207export async function reviewPlan($: EngineInterface, plan: string): Promise<string | null> {
208  const b = await hrOf($)
209  if (!b.isApproving || plan.trim() === '') return null
210  const now = await $.clock.now()
211  const reviewer = planReviewerOf(b)
212  const mistakes = DEPTS.flatMap(d => lessonsFor(b, d, now).map(l => `${DEPT_INFO[d].ko}: ${l.tool} "${l.pattern}"`)).slice(0, 6)
213  const ask = planRequestOf(reviewer, plan, mistakes)
214  const reply = await $.model.complete({ model: APPROVER_MODEL, system: ask.system, prompt: ask.prompt, maxTokens: 400, effort: 'low', timeoutMs: APPROVAL_TIMEOUT_MS })
215  const r = reply.isAnswered ? planReviewOf(reply.text) : null
216  if (r === null) {
217    $.ui.toast(`${reviewer} 계획 검토: 보류(검토자가 답하지 못함)`)
218    return null
219  }
220  $.ui.toast(`${reviewer} 계획 검토: ${r.isFit ? '적합' : `보완${r.notes[0] === undefined ? '' : ` · ${r.notes[0]}`}`}`)
221  await speak($, null, null, approvalLineOf(reviewer, `계획 검토 ${r.isFit ? '적합' : `보완${r.notes[0] === undefined ? '' : ` — ${r.notes[0]}`}`}`, await $.clock.now()))
222  return r.isFit ? null : planNoteOf(reviewer, r)
223}
224
225/** 띄운 쪽이 읽을 결재 결과를 꺼낸다(메인은 전부, 다른 쪽은 그가 띄운 에이전트 것만) */
226export function takeNotes(callerId: string, agents: readonly OfficeAgent[]): string[] {
227  // 메인은 자기 몫과, 띄운 쪽이 이미 끝나 읽을 수 없는 몫을 가져간다
228  const isGone = (id: string) => agents.find(a => a.id === id)?.endedAt !== null
229  const mine = notes.filter(n => n.parentId === callerId || (callerId === 'main' && isGone(n.parentId)))
230  notes = notes.filter(n => !mine.includes(n))
231  return mine.map(n => n.text)
232}
233
234/** 부서 예산: 넘었는데 아직 알리지 않은 부서를 알린다(고르고 적는 일은 한 번의 저장 안에서: 나란히 불려도 한 번만 알리도록) */
235export async function checkBudget($: EngineInterface) {
236  const b = await hrOf($)
237  if (Object.keys(b.budgets).length === 0) return
238  const agents = await agentsOf($)
239  const now = await $.clock.now()
240  if (overrunsOf(b, agents, now).length === 0) return
241  let over: Overrun[] = []
242  await saveHr($, x => {
243    over = overrunsOf(x, agents, now)
244    return noticed(x, over.map(o => o.dept), now)
245  })
246  for (const o of over) {
247    $.ui.toast(`${overrunText(o)}. 한도를 바꾸려면 /office budget ${o.dept} <달러>`)
248    await speak($, null, null, hrLineOf(overrunText(o), now))
249  }
250}
251
252/** 누군가(별칭 by) 파일을 고쳤다: 방금 끝낸 다른 사람의 일이면 그 기록에 후속 수정을 적는다(결재 정확도). 재작업 감점보다 먼저 본다 */
253export async function checkFollowUp($: EngineInterface, file: string, by: string) {
254  if ((await hrOf($)).handoffs.length === 0) return
255  const now = await $.clock.now()
256  // 고칠 것이 없으면 쓰지 않는다(파일을 고칠 때마다 인사 기록을 다시 쓰지 않도록)
257  const b = await hrOf($)
258  if (followUp(b, file, by, now) === b) return
259  await saveHr($, b => followUp(b, file, by, now))
260}
261
262/** BOSS가 파일을 고쳤다: 방금 끝낸 직원의 파일이면 재작업으로 깎는다 */
263export async function checkRework($: EngineInterface, file: string) {
264  if ((await hrOf($)).handoffs.length === 0) return
265  const now = await $.clock.now()
266  let r: ReturnType<typeof rework> | null = null
267  await saveHr($, b => {
268    r = rework(b, file, now)
269    return r.book
270  })
271  const hit = r as ReturnType<typeof rework> | null
272  if (hit === null || hit.nick === null) return
273  $.ui.toast(`BOSS가 ${hit.nick}의 작업(${baseName(file)})을 다시 고쳤습니다 · 재작업 -${REWORK_PENALTY}${movedText(hit)}`)
274}
275
276let syncing: Promise<void> | null = null
277/** 동기화는 한 번에 하나만 돈다: 겹치면 같은 에이전트를 두 번 평가한다 */
278export function syncStatus($: EngineInterface): Promise<void> {
279  syncing ??= syncOnce($).finally(() => {
280    syncing = null
281  })
282  return syncing
283}
284
285/** $.agent.list()의 상태를 옮긴다(끝남·실패·대기, 이름). 방금 끝난 에이전트는 평가받고, 띄운 쪽에게 보고하러 회의실에 간다. */
286async function syncOnce($: EngineInterface) {
287  const infos = await $.agent.list()
288  const now = await $.clock.now()
289  const known = await agentsOf($)
290  const reports: [string, string][] = []
291  const finished: { a: OfficeAgent; isDone: boolean; isKilled: boolean }[] = []
292  // 바뀐 칸만 모은다: 기다리는 사이 다른 훅이 고친 칸(연속 횟수 등)을 덮지 않도록
293  const updates = new Map<string, Pick<OfficeAgent, 'activity' | 'name' | 'parentId' | 'endedAt'>>()
294  for (const a of known) {
295    const info = infos.find(i => i.id === a.id)
296    if (info === undefined) continue
297    const ended = info.status === 'completed' || info.status === 'failed' || info.status === 'killed'
298    const activity: OfficeActivity = info.status === 'completed' ? 'done' : ended ? 'failed'
299      : info.status === 'idle' || info.status === 'waiting' ? 'idle'
300      : a.activity === 'done' || a.activity === 'failed' || a.activity === 'idle' ? 'think' : a.activity
301    const name = info.name ?? a.name ?? null
302    if (activity === a.activity && ended === (a.endedAt !== null) && name === (a.name ?? null)) continue
303    if (ended && a.endedAt === null) finished.push({ a, isDone: info.status === 'completed', isKilled: info.status === 'killed' })
304    if (activity === 'done' && a.activity !== 'done') reports.push([a.id, a.parentId ?? 'main'])
305    updates.set(a.id, { activity, name, parentId: a.parentId ?? info.parentId ?? 'main', endedAt: ended ? a.endedAt ?? now : null })
306  }
307  // 끝난 사람들은 끝남을 적기 전에 다 붙잡는다(평가하기 전에, 함께 끝난 앞사람의 평가 뒤나 다른 일의 결재 뒤 조직 개편이 내보내지 않도록)
308  for (const f of finished) hold(f.a.nick)
309  // 이 mod가 불러와지기 전에 뜬 에이전트도 들인다
310  const newcomers = infos.filter(i => !known.some(a => a.id === i.id) && (i.status === 'running' || i.status === 'pending'))
311  // 평가를 시작하지 못한 사람은 놓는다(저장이나 앞사람 평가가 던져도 세션 내내 붙잡혀 조직 개편에서 빠지지 않도록). 평가는 제가 놓는다
312  let started = 0
313  try {
314    if (updates.size > 0 || newcomers.length > 0) {
315      await save($, current => {
316        let list = current.map(a => {
317          const u = updates.get(a.id)
318          return u === undefined ? a : { ...a, ...u }
319        })
320        // 보고하러 간 사람은 "done!", 받는 쪽은 "thanks!"
321        for (const pair of reports) {
322          list = meet(pair, now + MEET_MS)(list)
323          list = patch(pair[0], a => ({ ...a, say: reportLineOf(a.nick, '', '', now).bubble }))(list)
324          list = patch(pair[1], a => ({ ...a, say: replyOf('thanks!', now) }))(list)
325        }
326        return list
327      })
328      for (const i of newcomers) await admit($, { ...fresh(i.id, i.type, i.description, now), name: i.name ?? null, parentId: i.parentId ?? 'main' })
329      // 메신저에는 보고 앞부분
330      for (const [childId, parentId] of reports) {
331        const child = (await agentsOf($)).find(a => a.id === childId)
332        if (child !== undefined) await speak($, null, null, reportLineOf(child.nick, await nickOfId($, parentId), papers.get(childId)?.report ?? '', now).line)
333      }
334      for (const f of finished) {
335        started++
336        await appraise($, f.a, f.isDone, f.isKilled)
337      }
338    }
339  } finally {
340    for (const f of finished.slice(started)) release(f.a.nick)
341  }
342  // 상태를 옮긴 뒤에 본다(방금 끝난 사람을 야근이라 하지 않도록)
343  const working = new Set(infos.filter(i => i.status === 'running' || i.status === 'pending').map(i => i.id))
344  await checkOvertime($, working)
345}
346
347/** 오래 일하는 직원을 한 번씩 알린다(엔진이 지금 일하는 중이라고 하는 사람만, 대기 중은 빼고) */
348async function checkOvertime($: EngineInterface, working: ReadonlySet<string>) {
349  const now = await $.clock.now()
350  const late = (await agentsOf($)).filter(a => a.id !== 'main' && working.has(a.id) && a.activity !== 'idle'
351    && a.isOvertimeNoticed !== true && isOvertime(a.startedAt, a.endedAt, now))
352  if (late.length === 0) return
353  await save($, list => list.map(a => (late.some(l => l.id === a.id) ? { ...a, isOvertimeNoticed: true } : a)))
354  for (const a of late) $.ui.toast(`${a.nick}가 ${Math.floor((now - a.startedAt) / 60_000)}분째 야근 중입니다: ${a.description || shortType(a.type)}`)
355}
356
357/**
358 * 근태: 같은 오류를 연달아 내거나 같은 호출을 되풀이하면 사용자에게 알리고 기록한다.
359 * 코칭이 켜져 있으면 그 직원에게 붙일 알림을 돌려준다.
360 */
361export async function watch($: EngineInterface, id: string, e: { tool: string } & object, ran: { deny?: string; text?: string; isError?: boolean }): Promise<string | null> {
362  // 거절은 그 직원의 실수가 아니다(같은 거절 문구가 되풀이돼도 세지 않는다)
363  if (ran.deny !== undefined) return null
364  const errKey = ran.isError === true ? errorKeyOf(e.tool, ran.text ?? '', e) : null
365  const isPoll = isPolling(e.tool)
366  const got: { kind?: 'error' | 'repeat' | null; s?: Streak; nick?: string } = {}
367  // 지금 상태에서 바로 센다: 같은 직원의 도구가 나란히 돌아도 서로의 횟수를 잃지 않도록
368  await save($, patch(id, a => {
369    // 도구마다 따로 센다. 사이에 다른 도구가 끼거나 실패해도 이어 세고(Edit 실패 → Read → Edit 실패가 흔한 헛손질), 그 도구가 성공하면 끊는다
370    const errStreaks = { ...(a.errStreaks ?? {}) }
371    if (errKey !== null) errStreaks[e.tool] = stepOf(errStreaks[e.tool], errKey)
372    else delete errStreaks[e.tool]
373    const callStreak = isPoll ? a.callStreak : stepOf(a.callStreak, callKeyOf(e))
374    const err = errKey === null ? undefined : errStreaks[e.tool]
375    const call = isPoll ? undefined : callStreak
376    // 오류 알림은 그 오류가 난 호출에서만. 같은 호출이 같은 오류로 되풀이되면 오류 알림 하나로 친다(제자리걸음은 성공한 호출만)
377    const kind = err !== undefined && isAlarm(err, ERROR_STREAK) ? 'error'
378      : errKey === null && call !== undefined && isAlarm(call, REPEAT_STREAK) ? 'repeat' : null
379    got.kind = kind
380    got.s = kind === 'error' ? err : call
381    got.nick = a.nick
382    // 이번 일의 근태 알림도 같은 저장에서 센다(곧 끝나 평가돼도 기록에서 빠지지 않도록)
383    return { ...a, errStreaks, callStreak, alarms: (a.alarms ?? 0) + (kind === null ? 0 : 1) }
384  }))
385  const kind = got.kind ?? null
386  if (kind === null || got.nick === undefined || got.s === undefined) return null
387  const s = got.s
388  const nick = got.nick
389  $.ui.toast(kind === 'error' ? errorToast(nick, e.tool, s) : repeatToast(nick, e.tool, s))
390  await saveHr($, b => warn(b, nick))
391  return (await hrOf($)).isCoaching ? nudgeOf(kind, e.tool, s) : null
392}
393
394/**
395 * 기다려서 받은 서브에이전트의 보고: 그 결재가 끝날 때까지 기다린다(반려 의견을 같은 결과에 붙이도록).
396 * 남은 시간 안에서만 기다린다(다른 디스패치의 약속을 기다리는 시간은 이 훅의 몫이다). 못 받으면 의견은 다음 도구 결과로 넘어간다
397 */
398export async function awaitApproval($: EngineInterface, child: string, remainingMs: number) {
399  const stop = new AbortController()
400  const deadline = $.clock.sleep(Math.max(1, Math.min(remainingMs, 60_000) - 2_000), { signal: stop.signal }).catch(() => undefined)
401  let isLate = false
402  void deadline.then(() => {
403    isLate = true
404  })
405  const within = async (p: Promise<unknown>) => {
406    if (!isLate) await Promise.race([p, deadline])
407  }
408  await within(syncStatus($))
409  // 이미 돌던 동기화가 이 에이전트의 끝남을 못 봤으면 한 번 더 본다
410  const known = (await agentsOf($)).find(a => a.id === child)
411  if (!approvals.has(child) && known !== undefined && known.endedAt === null) await within(syncStatus($))
412  const pending = approvals.get(child)
413  if (pending !== undefined) await within(pending)
414  stop.abort()
415}
416
hooks/tokens.ts 130 lines
1// 토큰 사용량과 비용. 모델을 부를 때마다 엔진이 알려 주는 네 가지 수(입력·출력·캐시 읽기·캐시 쓰기)를 더하고,
2// 그 모델의 API 정가로 달러를 어림해 함께 쌓는다. 직원이 일하며 쓴 것은 그 일 기록과 직원 누적에, 결재자가 그 일을
3// 결재하며 쓴 것은 그 일 기록에 따로 둔다. 순수 함수만 둔다.
4import type { HrBook, TokenUse } from '../types'
5
6/** 엔진이 주는 사용량(API의 철자). 빠진 수는 0으로 본다 */
7type Usage = { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number }
8
9const count = (x: unknown): number => (typeof x === 'number' && Number.isFinite(x) && x > 0 ? x : 0)
10
11/** 백만 토큰당 달러: 입력·출력·캐시 읽기. 캐시 쓰기는 입력의 1.25배(5분 캐시)로 본다(엔진은 1시간 캐시 쓰기를 따로 알려 주지 않는다) */
12export type Price = { input: number; output: number; cacheRead: number }
13export const CACHE_WRITE_RATE = 1.25
14
15/**
16 * Anthropic API 정가(2026-10 기준). 모델 id에 들어 있는 이름으로 찾는다(긴 이름부터: "opus-5-5"가 "opus-5"보다 먼저).
17 * 버전 없는 별칭(haiku·sonnet·opus)은 지금 세대로 본다
18 */
19const PRICES: [string, Price][] = [
20  ['fable-5-1', { input: 10, output: 50, cacheRead: 0.25 }],
21  ['mythos-5-1', { input: 10, output: 50, cacheRead: 0.25 }],
22  ['fable-5', { input: 10, output: 50, cacheRead: 1 }],
23  ['mythos-5', { input: 10, output: 50, cacheRead: 1 }],
24  ['opus-5-5', { input: 4, output: 20, cacheRead: 0.2 }],
25  ['opus-5', { input: 5, output: 25, cacheRead: 0.5 }],
26  ['opus-4-8', { input: 5, output: 25, cacheRead: 0.5 }],
27  ['opus-4-7', { input: 5, output: 25, cacheRead: 0.5 }],
28  ['opus-4-6', { input: 5, output: 25, cacheRead: 0.5 }],
29  ['sonnet-5-5', { input: 2, output: 10, cacheRead: 0.2 }],
30  ['sonnet-5', { input: 2, output: 10, cacheRead: 0.2 }],
31  ['sonnet-4-6', { input: 3, output: 15, cacheRead: 0.3 }],
32  ['haiku-5-5', { input: 0.1, output: 0.5, cacheRead: 0.01 }],
33  ['haiku-4-5', { input: 1, output: 5, cacheRead: 0.1 }],
34]
35const ALIASES: Record<string, string> = { fable: 'fable-5-1', opus: 'opus-5-5', sonnet: 'sonnet-5-5', haiku: 'haiku-5-5' }
36
37/** 모델 id(또는 별칭)의 가격, 모르면 null */
38export function priceOf(model: string): Price | null {
39  const m = model.toLowerCase().replace(/[.\s]/g, '-')
40  const alias = ALIASES[m.replace(/^claude-/, '').replace(/\[.*\]$/, '')]
41  // 이름 뒤에는 끝·날짜(-20260101)·플랫폼 꼬리(@, [1m], :, -v1)만 온다: "opus-5"가 앞으로 나올 "opus-5-1"을 잡지 않도록
42  const key = alias ?? PRICES.find(([k]) => new RegExp(`${k}(?=$|[@\\[:]|-\\d{8}|-v\\d)`).test(m))?.[0]
43  return PRICES.find(([k]) => k === key)?.[1] ?? null
44}
45
46// 버전은 한두 자리만(뒤에 붙는 8자리 날짜를 버전으로 읽지 않도록), 계열 이름은 낱말 앞에서만
47const FAMILY = /(?:^|-)(fable|mythos|opus|sonnet|haiku)(?:-(\d{1,2})(?!\d)(?:-(\d{1,2})(?!\d))?)?/
48const OLD_FAMILY = /(?:^|-)(\d)(?:-(\d))?-(opus|sonnet|haiku)/
49
50/** 모델 이름을 짧게: claude-opus-5-5-20261001 → Opus 5.5, claude-3-5-haiku → Haiku 3.5, claude-3-opus → Opus 3, opus → Opus. 모르는 이름은 앞 12자 */
51export function modelLabel(model: string): string {
52  const m = model.toLowerCase().replace(/[.\s]/g, '-').replace(/^claude-/, '').replace(/\[.*\]$/, '')
53  const cap = (f: string) => f[0]?.toUpperCase() + f.slice(1)
54  const old = m.match(OLD_FAMILY)
55  if (old !== null) return `${cap(old[3] ?? '')} ${old[1]}${old[2] === undefined ? '' : `.${old[2]}`}`
56  const hit = m.match(FAMILY)
57  if (hit === null) return model.slice(0, 12)
58  const [, family = '', major, minor] = hit
59  return major === undefined ? cap(family) : `${cap(family)} ${major}${minor === undefined ? '' : `.${minor}`}`
60}
61
62/** 사용량을 그 모델 가격으로 어림한 달러 */
63export const usdOf = (t: TokenUse, p: Price): number =>
64  (t.input * p.input + t.output * p.output + t.cacheRead * p.cacheRead + t.cacheWrite * p.input * CACHE_WRITE_RATE) / 1_000_000
65
66/** 모델 호출 한 번의 사용량. 그 모델의 가격을 알면 달러를, 모르면 가격 모르는 토큰으로 적는다 */
67export function usageOf(u: Usage, model: string | null = null): TokenUse {
68  const t: TokenUse = { input: count(u.input_tokens), output: count(u.output_tokens), cacheRead: count(u.cache_read_input_tokens), cacheWrite: count(u.cache_creation_input_tokens), usd: 0, unpriced: 0 }
69  const p = model === null ? null : priceOf(model)
70  return p === null ? { ...t, unpriced: totalOf(t) } : { ...t, usd: usdOf(t, p) }
71}
72
73export const addTokens = (a: TokenUse | undefined, b: TokenUse | undefined): TokenUse => ({
74  input: (a?.input ?? 0) + (b?.input ?? 0), output: (a?.output ?? 0) + (b?.output ?? 0),
75  cacheRead: (a?.cacheRead ?? 0) + (b?.cacheRead ?? 0), cacheWrite: (a?.cacheWrite ?? 0) + (b?.cacheWrite ?? 0),
76  usd: (a?.usd ?? 0) + (b?.usd ?? 0), unpriced: (a?.unpriced ?? 0) + (b?.unpriced ?? 0),
77})
78
79/** 네 가지를 다 더한 수(모델이 읽고 쓴 토큰 전부) */
80export const totalOf = (t: TokenUse | undefined): number => (t === undefined ? 0 : t.input + t.output + t.cacheRead + t.cacheWrite)
81
82/** 짧게: 850, 12.3k, 120k, 1.2M */
83export function tokenText(n: number): string {
84  // 반올림하면 윗자리가 되는 수는 윗 단위로 적는다(999.7 → 1.0k, 99,960 → 100k, 999,950 → 1.0M)
85  if (Math.round(n) < 1000) return `${Math.round(n)}`
86  if (n < 99_950) return `${(n / 1000).toFixed(1)}k`
87  if (n < 999_500) return `${(n / 1000).toFixed(0)}k`
88  return `${(n / 1_000_000).toFixed(1)}M`
89}
90
91/** 자세히: 12.3k(입력 1.2k · 출력 800 · 캐시 읽기 10.0k · 캐시 쓰기 300) · 비용 약 $0.04 */
92export const tokenDetail = (t: TokenUse): string =>
93  `${tokenText(totalOf(t))}(입력 ${tokenText(t.input)} · 출력 ${tokenText(t.output)} · 캐시 읽기 ${tokenText(t.cacheRead)} · 캐시 쓰기 ${tokenText(t.cacheWrite)}) · 비용 약 ${costText(t)}`
94
95/** 저장된 값을 읽는다(모양이 다르면 없던 것으로) */
96export function tokensOf(x: unknown): TokenUse | undefined {
97  if (typeof x !== 'object' || x === null) return undefined
98  const v = x as Record<string, unknown>
99  const t = { input: count(v.input), output: count(v.output), cacheRead: count(v.cacheRead), cacheWrite: count(v.cacheWrite) }
100  const sum = t.input + t.output + t.cacheRead + t.cacheWrite
101  if (sum === 0) return undefined
102  // 달러를 세기 전(v0.10)의 기록은 모두 가격 모르는 토큰으로 본다
103  return typeof v.usd === 'number' ? { ...t, usd: count(v.usd), unpriced: Math.min(count(v.unpriced), sum) } : { ...t, usd: 0, unpriced: sum }
104}
105
106/** 그 일(직원·몇 번째 일)을 결재하며 쓴 토큰을 그 일 기록에 더한다 */
107export const chargeReview = (book: HrBook, nick: string, job: number, t: TokenUse): HrBook =>
108  totalOf(t) === 0 ? book : { ...book, log: book.log.map(r => (r.nick === nick && r.job === job ? { ...r, reviewTokens: addTokens(r.reviewTokens, t) } : r)) }
109
110/** 달러: $0.0042, $0.12, $3.40, $120 */
111export function usdText(n: number): string {
112  if (n < 0.01) return `$${n.toFixed(4)}`
113  if (n < 100) return `$${n.toFixed(2)}`
114  return `$${Math.round(n).toLocaleString('en-US')}`
115}
116
117/** 비용 한마디: "$0.12", 가격을 모르는 토큰이 섞였으면 "$0.12+(가격 모름 3.2k)", 전부 모르면 "가격 모름" */
118export function costText(t: TokenUse | undefined): string {
119  if (t === undefined) return '-'
120  if (t.unpriced >= totalOf(t)) return '가격 모름'
121  return `${usdText(t.usd)}${t.unpriced > 0 ? `+(가격 모름 ${tokenText(t.unpriced)})` : ''}`
122}
123
124/** 여러 사용량의 합(하나도 없으면 undefined) */
125export const sumTokens = (list: readonly (TokenUse | undefined)[]): TokenUse | undefined =>
126  list.reduce<TokenUse | undefined>((s, t) => (t === undefined ? s : addTokens(s, t)), undefined)
127
128/** 토큰과 비용을 한마디로: 20.0k($0.12) */
129export const useText = (t: TokenUse): string => `${tokenText(totalOf(t))}(${costText(t)})`
130
hooks/menu.ts 63 lines
1// 사무실 메뉴 화면. 사무실 패널과 따로 여는 패널로, 위의 1~5 메뉴를 고르면 그 내용을 패널 안에 표(마크다운)로 보인다.
2// 무엇을 보고 있는지는 상태(menuView)에 둔다. 여기에는 메뉴 목록과 화면별 내용(블록)만 둔다.
3import type { EngineInterface } from 'claude-code'
4
5import type { HrBook } from '../types'
6import { budgetBlocks } from './budget'
7import { cardBlocks, findEmployee, reportBlocks, rosterBlocks } from './report'
8import type { Block } from './table'
9import { agentsOf, saveHr } from './store'
10import { APPROVER_MODEL } from './approval'
11
12export const MENU_PANE = 'agent-office-menu'
13export const MENU_TITLE = '사무실 메뉴'
14
15/** 메뉴: 단축키 1~5. view는 상태에 두는 화면 이름 */
16export const MENU = [
17  { key: 'menu-hr', hotkey: '1', label: '인사 명부', view: 'hr' },
18  { key: 'menu-today', hotkey: '2', label: '일간 보고', view: 'today' },
19  { key: 'menu-week', hotkey: '3', label: '주간 보고', view: 'week' },
20  { key: 'menu-budget', hotkey: '4', label: '예산', view: 'budget' },
21  { key: 'menu-settings', hotkey: '5', label: '설정', view: 'settings' },
22] as const
23
24/** 인사카드 화면의 이름: card:<별칭> */
25export const cardView = (nick: string): string => `card:${nick}`
26export const nickOfView = (view: string): string | null => (view.startsWith('card:') ? view.slice(5) : null)
27
28/** 설정 화면에서 켜고 끄는 것 */
29export const TOGGLES = [
30  { key: 'set-approval', label: '결재', note: `끝낸 일의 보고·확정한 계획을 결재(결재마다 ${APPROVER_MODEL}를 한 번 부름)`, isOn: (b: HrBook) => b.isApproving, flip: (b: HrBook): HrBook => ({ ...b, isApproving: !b.isApproving }) },
31  { key: 'set-reorg', label: '조직 개편', note: '계속 평가가 나쁘면 권고사직, 오래 일이 없으면 계약 종료', isOn: (b: HrBook) => b.isReorganizing, flip: (b: HrBook): HrBook => ({ ...b, isReorganizing: !b.isReorganizing }) },
32  { key: 'set-coach', label: '인사 메모', note: '업무 지시에 담당·지난 기록·반복 실수를 붙임', isOn: (b: HrBook) => b.isCoaching, flip: (b: HrBook): HrBook => ({ ...b, isCoaching: !b.isCoaching }) },
33  { key: 'set-trial', label: '교육 효과 측정', note: '실수 목록을 몇 건에 한 번 빼고 보내 효과를 잼', isOn: (b: HrBook) => b.isTrialing, flip: (b: HrBook): HrBook => ({ ...b, isTrialing: !b.isTrialing }) },
34] as const
35
36/** 설정 하나를 뒤집는다(저장한 값 위에서 뒤집으니 빨리 두 번 눌러도 한 번씩 바뀐다). 설정은 상태가 아니라 저장소에 있어 다시 그리게 한다 */
37export async function flip($: EngineInterface, key: string) {
38  const t = TOGGLES.find(x => x.key === key)
39  if (t === undefined) return
40  try {
41    await saveHr($, t.flip)
42  } finally {
43    $.ui.invalidate('ui.render')
44  }
45}
46
47/** 그 화면의 내용. 설정 화면은 버튼으로 그리므로 설명만 */
48export async function viewBlocks($: EngineInterface, b: HrBook, view: string): Promise<Block[]> {
49  const now = await $.clock.now()
50  const nick = nickOfView(view)
51  if (nick !== null) {
52    const who = findEmployee(b, nick)
53    return who === undefined ? [{ t: 'line', text: `${nick}라는 직원이 없습니다.` }] : cardBlocks(b, who, now)
54  }
55  if (view === 'today' || view === 'week') return reportBlocks(b, view, now, await agentsOf($))
56  if (view === 'budget') {
57    const blocks = budgetBlocks(b, await agentsOf($), now)
58    return blocks.length > 0 ? blocks : [{ t: 'title', text: '부서 예산' }, { t: 'line', text: '부서 예산이 없습니다. 정하려면 /office budget <부서> <달러> (예: /office budget 개발팀 5), 없애려면 /office budget <부서> off' }]
59  }
60  if (view === 'settings') return [{ t: 'title', text: '설정' }, { t: 'line', text: '누르면 바로 켜고 끕니다.' }]
61  return rosterBlocks(b, now)
62}
63
hooks/table.ts 117 lines
1// 글자 칸 맞춤 표. 터미널에서 한글·전각은 두 칸이라 글자 수가 아니라 칸 수로 맞춘다. 순수 함수만 둔다.
2
3/** 터미널에서 차지하는 칸 수(한글·한자·전각·이모지는 두 칸) */
4export function widthOf(s: string): number {
5  let n = 0
6  for (const ch of s) {
7    const c = ch.codePointAt(0) ?? 0
8    n += (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3)
9      || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6)
10      || (c >= 0x1f300 && c <= 0x1faff) ? 2 : 1
11  }
12  return n
13}
14
15/** 칸 수로 채운다: 왼쪽 맞춤('l')은 뒤에, 오른쪽 맞춤('r')은 앞에 빈칸 */
16export const pad = (s: string, width: number, align: 'l' | 'r' = 'l'): string => {
17  const fill = ' '.repeat(Math.max(0, width - widthOf(s)))
18  return align === 'r' ? `${fill}${s}` : `${s}${fill}`
19}
20
21/** 셀 하나로 다듬는다: 줄바꿈·탭 같은 공백은 한 칸으로, 칸 수가 max를 넘으면 잘라 …를 붙인다 */
22export function cellOf(s: string, max = Infinity): string {
23  const flat = s.replace(/\s+/g, ' ').trim()
24  if (widthOf(flat) <= max) return flat
25  let out = ''
26  for (const ch of flat) {
27    if (widthOf(out + ch) > max - 1) break
28    out += ch
29  }
30  return `${out.trimEnd()}…`
31}
32
33/**
34 * 머리줄·구분줄·내용 줄로 된 표. 칸 사이는 두 칸 띄운다. align은 칸마다('r'은 수), 없으면 왼쪽.
35 * max는 칸마다 최대 칸 수(업무 설명처럼 긴 글이 칸 전체를 넓히지 않도록), 없으면 끝없이.
36 * 맨 끝 칸은 왼쪽 맞춤이면 채우지 않고, 줄 끝 빈칸은 지운다.
37 */
38export function tableLines(head: readonly string[], rows: readonly (readonly string[])[], align: readonly ('l' | 'r')[] = [], max: readonly (number | undefined)[] = []): string[] {
39  const body = rows.map(r => head.map((_, i) => cellOf(r[i] ?? '', max[i])))
40  const widths = head.map((h, i) => Math.max(widthOf(h), ...body.map(r => widthOf(r[i] ?? ''))))
41  const last = head.length - 1
42  const line = (cells: readonly string[]) => cells.map((c, i) => (i === last && (align[i] ?? 'l') === 'l' ? c : pad(c, widths[i] ?? 0, align[i] ?? 'l'))).join('  ')
43  return [line(head), widths.map(w => '─'.repeat(w)).join('  '), ...body.map(line)].map(l => l.trimEnd())
44}
45
46// ── 문서 블록: 같은 내용을 글(명령 답)과 마크다운(메뉴 화면) 두 가지로 그린다 ─────────────
47
48export type Block =
49  | { t: 'title'; text: string }
50  | { t: 'line'; text: string }
51  | { t: 'gap' }
52  | { t: 'section'; text: string }
53  | { t: 'bullet'; text: string }
54  | { t: 'field'; label: string; value: string }
55  | { t: 'table'; head: readonly string[]; rows: readonly (readonly string[])[]; align?: readonly ('l' | 'r')[]; max?: readonly (number | undefined)[] }
56
57/** 이름표 줄의 이름 폭 */
58const FIELD_WIDTH = 12
59
60/** 글로: 표는 칸 맞춤, 절 머리는 ■, 글머리는 -, 이름표는 폭을 맞춘다 */
61export function textOf(blocks: readonly Block[]): string {
62  return blocks.flatMap(b => {
63    switch (b.t) {
64      case 'title': case 'line': return [b.text]
65      case 'gap': return ['']
66      case 'section': return [`■ ${b.text}`]
67      case 'bullet': return [`- ${b.text}`]
68      case 'field': return [`${pad(b.label, FIELD_WIDTH)}${cellOf(b.value)}`]
69      case 'table': return tableLines(b.head, b.rows, b.align, b.max)
70    }
71  }).join('\n')
72}
73
74/** 마크다운 화면이 한 번에 그리는 글자 수(엔진 한도) */
75export const MD_MAX = 100_000
76
77/**
78 * 마크다운에 넣을 글: 터미널 제어 문자와 글 방향 뒤집기를 지우고(엔진은 tab·줄바꿈 말고는 받지 않는다), 공백을 한 칸으로 접고,
79 * 마크다운 문법 글자는 글자 그대로 보이게 이스케이프한다(업무 설명 "fix __init__"이 굵게 그려지지 않도록)
80 */
81export const mdText = (s: string, max?: number): string =>
82  cellOf(s.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f‪-‮⁦-⁩]/g, ' '), max)
83    .replace(/[\\`*_[\]<>|#~]/g, ch => `\\${ch}`)
84
85/** 마크다운으로: 제목 ##, 절 ###, 표는 마크다운 표(수는 오른쪽 정렬), 이름표는 줄바꿈으로 잇고, 나머지는 문단 */
86export function mdOf(blocks: readonly Block[]): string {
87  const parts: { t: Block['t']; md: string }[] = []
88  for (const b of blocks) {
89    switch (b.t) {
90      case 'title': parts.push({ t: b.t, md: `## ${mdText(b.text)}` }); break
91      // 줄이 목록 표시(- , + , 1. )로 시작하면 목록으로 그려지지 않게 앞을 이스케이프한다
92      case 'line': parts.push({ t: b.t, md: mdText(b.text).replace(/^([-+]|\d+\.)(?= )/, '\\$1') }); break
93      case 'gap': break
94      case 'section': parts.push({ t: b.t, md: `### ${mdText(b.text)}` }); break
95      case 'bullet': parts.push({ t: b.t, md: `- ${mdText(b.text)}` }); break
96      case 'field': parts.push({ t: b.t, md: `**${mdText(b.label)}** ${mdText(b.value)}` }); break
97      case 'table': {
98        const row = (cells: readonly string[]) => `| ${cells.join(' | ')} |`
99        parts.push({ t: b.t, md: [
100          row(b.head.map(h => mdText(h))),
101          row(b.head.map((_, i) => ((b.align?.[i] ?? 'l') === 'r' ? '---:' : '---'))),
102          ...b.rows.map(r => row(b.head.map((_, i) => mdText(r[i] ?? '', b.max?.[i])))),
103        ].join('\n') })
104      }
105    }
106  }
107  // 글머리끼리는 이어 목록으로, 이름표끼리는 줄바꿈(줄 끝 두 칸)으로 잇고, 나머지는 문단으로 띄운다
108  const md = parts.reduce((s, x, i) => {
109    if (i === 0) return x.md
110    const prev = parts[i - 1]?.t
111    if (prev === 'bullet' && x.t === 'bullet') return `${s}\n${x.md}`
112    if (prev === 'field' && x.t === 'field') return `${s}  \n${x.md}`
113    return `${s}\n\n${x.md}`
114  }, '')
115  return md.length <= MD_MAX ? md : `${md.slice(0, MD_MAX - 20)}\n\n…(잘림)`
116}
117
hooks/watch.ts 103 lines
1// 근태. 일하는 모습에서 이상 징후를 찾는다: 같은 오류를 연달아 내거나(헛손질), 같은 도구를 같은 입력으로
2// 되풀이하거나(제자리걸음), 오래 붙잡고 있는(야근) 직원. 찾으면 사용자에게 토스트로 알리고, 코칭이 켜져 있으면
3// 그 직원이 도구 결과 뒤에 읽는 짧은 알림을 붙인다. 순수 함수만 둔다.
4import type { Streak } from '../types'
5import { patternOf, tipOf } from './hr'
6
7/** 같은 오류가 이만큼 연달아 나면 알린다(그 뒤로는 이 배수마다) */
8export const ERROR_STREAK = 3
9/** 같은 도구·같은 입력을 이만큼 연달아 부르면 알린다(그 뒤로는 이 배수마다) */
10export const REPEAT_STREAK = 4
11/** 이만큼 오래 일하면 야근 */
12export const OVERTIME_MS = 20 * 60_000
13
14/** 같은 열쇠면 이어 세고, 다르면 새로 센다 */
15export const stepOf = (prev: Streak | undefined, key: string): Streak =>
16  prev?.key === key ? { key, count: prev.count + 1 } : { key, count: 1 }
17
18/** 알릴 차례인가: 문턱에 닿았을 때와 그 배수마다 한 번씩 */
19export const isAlarm = (s: Streak | undefined, threshold: number): boolean =>
20  s !== undefined && s.count >= threshold && s.count % threshold === 0
21
22const hash = (s: string): string => {
23  let h = 7
24  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0
25  return h.toString(36)
26}
27
28// 호출마다 달라지는 겉봉 칸(입력이 아니다)
29const ENVELOPE = new Set(['tool', 'agentId', 'tool_use_id', 'consent', 'requestMeta'])
30
31/** 상태를 묻는 도구는 같은 입력으로 되풀이하는 게 정상이다(기다리며 다시 묻는다) */
32export const isPolling = (tool: string): boolean => /^(Task\w*|Monitor|ListAgents|ScheduleWakeup)$/.test(tool)
33
34/** 도구와 입력으로 만든 열쇠: 같은 호출을 되풀이하는지 본다 */
35export function callKeyOf(e: { tool: string } & object): string {
36  const input = Object.entries(e).filter(([k]) => !ENVELOPE.has(k)).sort(([a], [b]) => (a < b ? -1 : 1))
37  return `${e.tool}|${hash(JSON.stringify(input))}`
38}
39
40/** 셸 명령 도구(맥·리눅스의 Bash, 윈도우의 PowerShell): 입력이 같은 모양(command·description)이다 */
41export const isShell = (tool: string): boolean => tool === 'Bash' || tool === 'PowerShell'
42
43/** 테스트·빌드·타입 검사로 보이는 낱말 */
44const CHECK_RE = /\b(test|tests|build|tsc|jest|vitest|pytest|unittest|mocha|lint|typecheck|check|cargo|go\s+(test|build|vet)|make|gradlew?|mvn|ruff|mypy|eslint|playwright|cypress|ctest|pester|Invoke-Pester)\b/i
45/** 파일을 읽거나 찾기만 하는 명령은 확인이 아니다(grep foo test/의 "일치 없음"을 테스트 실패로 읽지 않도록) */
46const LOOK_RE = /^(cd|grep|rg|cat|ls|find|head|tail|echo|sed|awk|wc|less|more|git|Get-ChildItem|Get-Content|Select-String|Set-Location)$/i
47const isAssign = (w: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*=/.test(w)
48
49/** 명령 첫 줄을 &&·;로 나눈 조각들 */
50export const segmentsOf = (command: string): string[] =>
51  (command.trim().split('\n')[0] ?? '').split(/&&|;/).map(x => x.trim()).filter(x => x !== '')
52
53/** 그 조각이 확인(테스트·빌드)인가: 첫 낱말(환경 변수 대입은 건너뛰고)이 읽기 명령이 아니고, 확인 낱말이 있다 */
54export const isCheckSegment = (seg: string): boolean => {
55  const head = seg.split(/\s+/).find(w => !isAssign(w)) ?? ''
56  return !LOOK_RE.test(head) && CHECK_RE.test(seg)
57}
58
59/** 테스트·빌드·타입 검사로 보이는 셸 명령(Bash·PowerShell): 나눈 조각 중 하나라도 확인이면 */
60export const isCheckCommand = (e: object): boolean => {
61  const { command } = e as { command?: unknown }
62  return typeof command === 'string' && segmentsOf(command).some(isCheckSegment)
63}
64
65/**
66 * 오류의 열쇠: 같은 도구·같은 문구(경로·숫자를 지운)면 같은 실수.
67 * 셸 명령은 첫 줄이 늘 "Exit code N"이라 그 줄은 건너뛰고, 명령 이름까지 넣는다(grep 일치 없음과 테스트 실패는 다른 실수다).
68 */
69export function errorKeyOf(tool: string, text: string, e: object = {}): string {
70  if (!isShell(tool)) return `${tool}|${patternOf(text)}`
71  const { command } = e as { command?: unknown }
72  const head = typeof command === 'string' ? (command.trim().split(/\s+/).find(w => !w.includes('=')) ?? '') : ''
73  const body = text.split('\n').filter(l => !/^\s*exit code \d+\s*$/i.test(l)).join('\n')
74  return `${tool}|${head === '' ? '' : `${head}: `}${patternOf(body)}`
75}
76
77const patternIn = (key: string): string => key.slice(key.indexOf('|') + 1)
78
79/** 같은 실수를 연달아 낼 때 사용자에게 보일 토스트 */
80export const errorToast = (nick: string, tool: string, s: Streak): string =>
81  `${nick}가 같은 실수를 ${s.count}번 연달아 하고 있습니다: ${tool} "${patternIn(s.key)}"`
82
83/** 같은 호출을 되풀이할 때 사용자에게 보일 토스트 */
84export const repeatToast = (nick: string, tool: string, s: Streak): string =>
85  `${nick}가 같은 ${tool}를 같은 입력으로 ${s.count}번 연달아 부르고 있습니다(제자리걸음)`
86
87/** 그 직원이 도구 결과 뒤에 읽는 알림(짧게): 같은 방법을 그만두고 다른 길을 찾거나 보고하게 한다 */
88export function nudgeOf(kind: 'error' | 'repeat', tool: string, s: Streak): string {
89  const head = '[사내 근태 알림 · agent-office]'
90  if (kind === 'repeat') {
91    return `${head} 같은 ${tool} 호출을 같은 입력으로 ${s.count}번 연달아 했습니다. 결과는 바뀌지 않습니다. ` +
92      '이미 얻은 결과로 다음 단계로 넘어가거나, 막혔다면 지금까지 한 일과 막힌 곳을 보고하고 끝내세요.'
93  }
94  const pattern = patternIn(s.key)
95  const tip = tipOf(pattern)
96  return `${head} 같은 오류가 ${s.count}번 연달아 났습니다: ${tool} "${pattern}". 같은 방법을 되풀이하지 마세요. ` +
97    `${tip === null ? '원인을 다시 살피고' : `${tip}. 그래도 안 되면`} 다른 방법을 찾거나, 막혔다면 지금까지 한 일과 막힌 곳을 보고하고 끝내세요.`
98}
99
100/** 야근 중인가(일하는 중이고 오래됐다) */
101export const isOvertime = (startedAt: number, endedAt: number | null, now: number): boolean =>
102  endedAt === null && now - startedAt >= OVERTIME_MS
103
hooks/store.ts 174 lines
1// 사무실의 상태와 저장. 사무실 사람들($.state, 패널이 구독한다)과 인사 기록($.store, 세션을 넘어 남는다)을
2// 읽고 쓰는 길을 한곳에 둔다. 이 모듈의 사본은 다시 불러오면 비고, 상태에서 다시 채운다.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface } from 'claude-code'
5
6import type { Bubble, HrBook, OfficeAgent, TokenUse, Utterance } from '../types'
7import { assign, DEPTS, deptOf, emptyBook, hire, leadOf, migrate } from './hr'
8import { pickFor } from './org'
9import type { Assignment } from './org'
10import { pushLine } from './speech'
11import { addTokens, tokensOf, totalOf } from './tokens'
12import { nickFor } from './scene'
13import type { Leads } from './scene'
14
15/** 대화 한 번에 회의실에 머무는 시간 */
16export const MEET_MS = 8000
17/** 인사 기록을 세션을 넘어 남기는 $.store 키 */
18const HR_KEY = 'hr'
19const agentsAtom = atom({ plugin: 'agent-office', key: 'agents' } as const, [])
20const feedAtom = atom({ plugin: 'agent-office', key: 'feed' } as const, [])
21const menuViewAtom = atom({ plugin: 'agent-office', key: 'menuView' } as const, 'hr')
22
23// 이 모듈의 사본(상태가 원본, 다시 불러오면 상태에서 채운다)
24let cache: OfficeAgent[] | null = null
25let book: HrBook | null = null
26
27let loading: Promise<HrBook> | null = null
28export async function hrOf($: EngineInterface): Promise<HrBook> {
29  if (book !== null) return book
30  // 처음 읽는 동안 다른 호출이 와도 같은 읽기를 기다린다(먼저 고친 값을 덮지 않도록)
31  loading ??= $.store.get(HR_KEY).then(stored => {
32    book ??= migrate(stored) ?? emptyBook()
33    return book
34  })
35  return loading
36}
37
38export async function saveHr($: EngineInterface, fn: (b: HrBook) => HrBook) {
39  // 기다린 뒤의 최신 사본에 고친다(기다리는 사이 다른 저장이 고친 것을 덮지 않도록)
40  await hrOf($)
41  book = fn(book ?? emptyBook())
42  await $.store.set(HR_KEY, book)
43}
44
45export const leadsOf = (b: HrBook): Leads => Object.fromEntries(DEPTS.map(d => [d, leadOf(b, d)?.nick]).filter(([, n]) => n !== undefined))
46
47/** 직원 명부·떠난 직원·사무실 사람들의 별칭을 모두 피해 새 별칭을 정한다(떠난 직원의 별칭을 다시 쓰면 기록이 섞인다) */
48const newNick = (type: string, agents: readonly OfficeAgent[], b: HrBook) =>
49  nickFor(type, [...agents, ...[...b.employees, ...b.retired, ...b.log].map(e => ({ nick: e.nick, endedAt: 0 }))])
50
51/**
52 * 조직 개편이 건드리면 안 되는 직원(별칭 → 겹친 수): 배정됐는데 아직 사무실에 안 들어온 사람,
53 * 일은 끝났지만 평가·결재가 덜 끝난 사람. 일하는 중인 사람은 busyOf가 본다
54 */
55const holds = new Map<string, number>()
56export const hold = (nick: string) => {
57  holds.set(nick, (holds.get(nick) ?? 0) + 1)
58}
59export const release = (nick: string) => {
60  const n = (holds.get(nick) ?? 0) - 1
61  if (n <= 0) holds.delete(nick)
62  else holds.set(nick, n)
63}
64
65/** 그리는 쪽이 사무실 사람들과 메신저를 구독한다(상태가 바뀌면 다시 그린다) */
66export async function watchAgents($: EngineInterface) {
67  return read($, agentsAtom)
68}
69export async function readFeed($: EngineInterface) {
70  return read($, feedAtom)
71}
72/** 메뉴 화면에서 보고 있는 것: 상태에 두어 다시 불러와도 그대로이고, 바꾸면 그리는 쪽이 다시 그린다 */
73export async function readMenuView($: EngineInterface) {
74  return read($, menuViewAtom)
75}
76export async function setMenuView($: EngineInterface, view: string) {
77  await update($, menuViewAtom, () => view)
78}
79
80export async function agentsOf($: EngineInterface): Promise<OfficeAgent[]> {
81  if (cache === null) {
82    // 별칭·부서가 없던 예전 상태는 읽을 때 채운다
83    const b = await hrOf($)
84    const loaded: OfficeAgent[] = []
85    for (const a of await read($, agentsAtom)) {
86      const nick = a.nick || newNick(a.type, loaded, b)
87      // 토큰은 지금 모양으로 읽는다(달러를 세기 전 v0.10의 토큰은 가격 모름으로)
88      const { tokens: had, ...rest } = a
89      const tokens = tokensOf(had)
90      loaded.push({ ...rest, nick, dept: a.dept ?? (a.id === 'main' ? undefined : deptOf(a.type)), ...(tokens === undefined ? {} : { tokens }) })
91    }
92    cache = loaded
93  }
94  return cache
95}
96
97export async function save($: EngineInterface, fn: (list: OfficeAgent[]) => OfficeAgent[]) {
98  // 기다린 뒤의 최신 사본에 고친다(나란히 도는 저장끼리 서로 덮지 않도록)
99  await agentsOf($)
100  const next = fn(cache ?? [])
101  // 끝난 에이전트는 최근 30명까지만 남긴다
102  const finished = next.filter(a => a.endedAt !== null).sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0)).slice(30)
103  cache = next.filter(a => !finished.includes(a))
104  await update($, agentsAtom, () => cache ?? [])
105}
106
107export const patch = (id: string, fn: (a: OfficeAgent) => OfficeAgent) => (list: OfficeAgent[]) => list.map(a => (a.id === id ? fn(a) : a))
108
109/** 두 사람을 회의실로 부른다(서로를 대화 상대로 적는다). */
110export const meet = (ids: [string, string], until: number) => (list: OfficeAgent[]) => {
111  const nickOf = (id: string) => list.find(a => a.id === id)?.nick ?? null
112  return list.map(a => (a.id === ids[0] ? { ...a, meetUntil: until, meetWith: nickOf(ids[1]) } : a.id === ids[1] ? { ...a, meetUntil: until, meetWith: nickOf(ids[0]) } : a))
113}
114
115/** 말하기: 그 사람 머리 위에 말풍선을 띄우고, 줄이 있으면 사내 메신저에 남긴다 */
116export async function speak($: EngineInterface, id: string | null, bubble: Bubble | null, line: Utterance | null = null) {
117  if (id !== null && bubble !== null) await save($, patch(id, a => ({ ...a, say: bubble })))
118  if (line !== null) await update($, feedAtom, f => pushLine(f, line))
119}
120
121/**
122 * 그 에이전트가 모델을 부르며 쓴 토큰을 더하고, 그때 답한 모델을 적는다(띄울 때 정한 별칭보다 정확하다).
123 * 사무실에 없거나 이미 끝난(평가에 넘긴) 직원이면 버린다(목록과 인사 기록이 어긋나지 않도록)
124 */
125export async function chargeAgent($: EngineInterface, id: string, t: TokenUse, model: string | null = null) {
126  const a = (await agentsOf($)).find(x => x.id === id)
127  if (totalOf(t) === 0 || a === undefined || (id !== 'main' && a.endedAt !== null)) return
128  await save($, patch(id, a => ({ ...a, tokens: addTokens(a.tokens, t), ...(model === null || model === '' ? {} : { model }) })))
129}
130
131export async function nickOfId($: EngineInterface, id: string): Promise<string> {
132  return (await agentsOf($)).find(a => a.id === id)?.nick ?? (id === 'main' ? 'BOSS' : '(퇴근한 직원)')
133}
134
135/** 지금 일하는 사람들의 별칭 */
136export const busyOf = (agents: readonly OfficeAgent[]): Set<string> => new Set(agents.filter(a => a.endedAt === null).map(a => a.nick))
137/** 조직 개편이 건드리면 안 되는 별칭: 지금(이 순간의 사본) 일하는 사람과 붙잡아 둔 사람. agentsOf로 사본을 채운 뒤에 부른다 */
138export const keptNow = (): Set<string> => new Set([...busyOf(cache ?? []), ...holds.keys()])
139
140export const fresh = (id: string, type: string, description: string, now: number): Omit<OfficeAgent, 'nick'> => ({
141  id, type, description, model: null, startedAt: now, endedAt: null, activity: type === 'main' ? 'idle' : 'think', tool: null, tools: 0,
142  dept: type === 'main' ? undefined : deptOf(type), errors: 0,
143})
144
145/**
146 * 일을 맡을 직원을 정한다: 업무 설명이 있으면 그 부서에서 쉬고 있는 직원 중 적임자(비슷한 일의 실적·평균 평가),
147 * 없으면 경력이 가장 높은 사람. 아무도 없으면 새로 뽑는다. 정한 직원은 인사 기록에 남는다.
148 */
149export async function staff($: EngineInterface, type: string, description = ''): Promise<Assignment> {
150  const b = await hrOf($)
151  const dept = deptOf(type)
152  const agents = await agentsOf($)
153  const busy = busyOf(agents)
154  const picked = description.trim() === '' ? null : pickFor(b, dept, busy, description)
155  if (picked !== null) return picked
156  const had = assign(b, dept, busy)
157  if (had !== undefined) return { employee: had, why: null }
158  const hired = hire(b, newNick(type, agents, b), dept, type, await $.clock.now())
159  await saveHr($, () => hired.book)
160  return { employee: hired.employee, why: null }
161}
162
163/** 사무실에 들인다(별칭이 정해져 있으면 그대로, 같은 id가 있으면 바꾼다) */
164export async function admit($: EngineInterface, agent: Omit<OfficeAgent, 'nick'>, nick?: string, extra: (list: OfficeAgent[]) => OfficeAgent[] = l => l) {
165  const chosen = nick ?? (agent.type === 'main' ? 'BOSS' : (await staff($, agent.type)).employee.nick)
166  await save($, list => extra([...list.filter(a => a.id !== agent.id), { ...agent, nick: chosen }]))
167}
168
169export async function ensure($: EngineInterface, id: string) {
170  if ((await agentsOf($)).some(a => a.id === id)) return
171  const now = await $.clock.now()
172  await admit($, fresh(id, id === 'main' ? 'main' : 'agent', id === 'main' ? '메인 대화' : '', now))
173}
174