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

한국어 | 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 명부로). 설정 화면에서는 결재·조직 개편·인사 메모·교육 효과 측정을 바로 켜고 끕니다. 단축키는 그 패널에 포커스가 있을 때 듣습니다.
SendMessage, 끝내고 보고) → 회의실에 모여 말풍선BOSS, 서브에이전트는 동물 별칭(OWL, BEAR, HAWK…)을 받습니다.edit hr.ts, $ npm test, grep fooBEAR, go! → yes!, 메시지 -> OWL, 보고 받기 thanks!done!, 실수(빨강) oops!12초 전 BOSS → BEAR (지시): T139 빌드 — 만들어BEAR (혼잣말): 테스트 돌려 보기: Bash 명령에 붙은 설명BEAR: 앗, Edit 실패 — …OWL → BEAR (메시지): …BEAR → BOSS (보고): 고쳤습니다…대표 BOSS (결재): BEAR "고치기" 반려 — …***로 가립니다. Bash 말풍선에는 명령 이름만 싣습니다. 메신저는 세션 상태(agent-office.feed)라 같은 세션의 다른 mod도 읽을 수 있습니다.npm test, tsc, pytest 등. grep 같은 다른 명령은 보지 않음)이 실패한 채 완료하면 −4.일하는 모습에서 이상 징후를 찾아 토스트로 알리고, 인사 기록에 근태 알림 횟수를 남깁니다(점수는 깎지 않음).
grep의 일치 없음과 npm test 실패는 다른 실수), 사용자·권한 거절은 세지 않습니다.TaskGet·Monitor처럼 상태를 묻는 도구는 뺌).야근을 붙입니다./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. 꺼도 평가·연봉 기록은 계속 남습니다.
도구 호출은 하나도 막지 않습니다. 결재 대상은 끝낸 일의 보고와 확정한 계획입니다.
||·파이프·;·백그라운드로 돌린 명령은 종료 코드를 믿을 수 없어 "알 수 없음"으로 적습니다./office approval off./office report에 싣습니다. 판정된 반려가 3건 이상인데 맞은 것이 1/3 아래인 결재자의 반려는 감점하지 않고, 적임자를 고를 때도 그르친 일로 치지 않습니다(의견은 그대로 메인에게 갑니다). 같은 직원이 다음 일에서 제 파일을 더 고친 것은 후속 수정으로 치지 않습니다. 후속 수정이 다음 단계 작업일 수도 있어 거친 잣대입니다./office reorg on(기본)이면 일이 끝나 평가·결재가 다 끝난 뒤, 그리고 세션을 열 때 직원 명부를 봅니다. 지금 일하는 직원, 일을 배정받아 들어오는 중인 직원, 평가·결재가 덜 끝난 직원은 건드리지 않습니다. 처음 내보낼 때 한 번 안내합니다.
떠난 직원은 토스트와 메신저(인사팀 발령)로 알리고, /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가 쓴 비용은 부서가 없어 어느 예산에도 들지 않습니다.
메모에 붙이는 실수 목록이 정말 재발을 줄이는지 잽니다.
/office hr에 측정 중: 메모 8건 중 재발 1(13%) / 비교군 3건 중 재발 2(67%)처럼 나옵니다./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
hooks/register.tsx 338 lines1// 에이전트 사무실 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}
338hooks/hr.ts 362 lines1// 인사 제도. 에이전트 종류로 부서를 정하고, 같은 부서 직원(별칭)이 일을 맡을 때마다 경력이 쌓인다.
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}
362hooks/speech.ts 171 lines1// 사내 대화. 직원들이 일하며 하는 말을 만든다: 평면도 머리 위에 잠깐 뜨는 짧은 말풍선(영문, 평면도 글자는 영문뿐)과
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}
171hooks/trial.ts 77 lines1// 교육 효과 측정. 인사 메모에 붙이는 "반복한 실수"가 정말 재발을 줄이는지 본다.
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}
77hooks/scene.ts 717 lines1// 위에서 내려다본 게임 속 사무실. 위층에 사장실·회의실·자료실·서버실·화장실, 가운데 복도(왼쪽 끝 입구와 안내 데스크),
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}
717hooks/commands.ts 87 lines1// /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}
87hooks/flow.ts 416 lines1// 업무 흐름. 엔진이 알려 주는 에이전트 상태를 사무실로 옮기고(끝남·실패·대기), 끝낸 일을 평가해 인사 기록에 남기고,
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}
416hooks/tokens.ts 130 lines1// 토큰 사용량과 비용. 모델을 부를 때마다 엔진이 알려 주는 네 가지 수(입력·출력·캐시 읽기·캐시 쓰기)를 더하고,
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)})`
130hooks/menu.ts 63 lines1// 사무실 메뉴 화면. 사무실 패널과 따로 여는 패널로, 위의 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}
63hooks/table.ts 117 lines1// 글자 칸 맞춤 표. 터미널에서 한글·전각은 두 칸이라 글자 수가 아니라 칸 수로 맞춘다. 순수 함수만 둔다.
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}
117hooks/watch.ts 103 lines1// 근태. 일하는 모습에서 이상 징후를 찾는다: 같은 오류를 연달아 내거나(헛손질), 같은 도구를 같은 입력으로
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
103hooks/store.ts 174 lines1// 사무실의 상태와 저장. 사무실 사람들($.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